A comprehensive developer’s guide to what changes at API version 67.0, why Salesforce made these changes, and exactly how to prepare your codebase before you bump the version number
Introduction
Every Salesforce release brings a mix of nice-to-haves and things you genuinely have to plan for. Summer ’26 has both, but this post is about the second kind. Apex’s security model is changing more significantly here than at almost any point since the platform’s early days and unlike most releases, this isn’t opt-in syntax sugar. It’s a shift in what your code does by default the moment you compile it at API version 67.0.
For fifteen-plus years, Apex operated on an assumption that ran counter to almost every other layer of the platform: while Lightning Experience, list views, reports, and dashboards all respect field-level security (FLS), object permissions, and sharing rules automatically, Apex code did not unless a developer explicitly wrote code to enforce it. That gap between “what the UI shows” and “what the underlying code can access” has been a known architectural risk for years, addressed piecemeal through tools like WITH SECURITY_ENFORCED, Security.stripInaccessible(), and the AccessLevel parameter. All of these were useful. All of them were also optional, which meant their effectiveness depended entirely on every developer remembering to use them, every time, on every query and DML statement, across every class in a codebase that might span years and multiple teams.
Summer ’26 changes the default instead of adding another opt-in tool. This is one of the most consequential Apex changes in the platform’s history, and it deserves a genuinely detailed walkthrough not just “this changed,” but why it changed, exactly how the mechanics work in every scenario you’re likely to hit, how it interacts with testing, and how to sequence a real migration without breaking production.
Alongside the security overhaul, Summer ’26 also ships a smaller but very real breaking change around invocable Apex actions used in Flow, plus a new beta developer tool. Here’s the complete picture.
1. Database Operations Run in User Mode by Default, Not System Mode
The Old Behavior
In API version 66.0 and earlier, Apex database operations SOQL, SOSL, DML statements, and Database class methods run in system mode by default. System mode ignores the running user’s object permissions, field-level security (FLS), and sharing rules entirely. A query like this returns every matching record and every field, regardless of what the user is actually allowed to see:
// API v66 and earlier — runs in system mode by default
List<Account> accs = [
SELECT Id, Name, AnnualRevenue, Sensitive_Financials__c
FROM Account
];
Even if the running user has no access to Sensitive_Financials__c, this query returns it anyway, because Apex database operations, such as SOQL queries, DML statements, and Database methods, default to system mode, meaning the current user can access all data regardless of their permissions. This is true whether the code runs synchronously from a button click, asynchronously in a Queueable, or as part of a scheduled batch job the execution context didn’t matter, only whether the operation itself declared an explicit access level.
This has real consequences beyond theoretical risk. Unlike the Lightning UI, which removes inaccessible fields, makes them read-only, or hides entire records the user shouldn’t see, Apex historically has not followed suit it doesn’t generate errors, doesn’t atomically clear variables the user shouldn’t see, and doesn’t block inserts or updates to fields the user lacks access to. That means a custom Visualforce page, LWC, or even a simple Apex-backed button could display information the user should never have seen, or allow an update that should have been blocked by their permission set not because anyone wrote insecure code on purpose, but because the default behavior of the platform never enforced it.
The New Behavior
At API version 67.0, the default flips. The same query, compiled at v67.0, now enforces the running user’s sharing rules, field-level security, and object permissions automatically no extra syntax required:
// API v67.0+ — runs in user mode by default
List<Account> accs = [
SELECT Id, Name, AnnualRevenue, Sensitive_Financials__c
FROM Account
];
// If the running user can't see Sensitive_Financials__c, this may
// throw an access exception or simply not return that field —
// behavior depends on the specific operation and access model.
This applies uniformly across SOQL, SOSL, and DML:
// SOQL — enforces FLS and sharing automatically at v67.0+
List<Contact> contacts = [SELECT Id, Email, SSN__c FROM Contact];
// SOSL — same enforcement applies to search results
List<List<SObject>> searchResults = [FIND 'Acme' IN ALL FIELDS
RETURNING Account(Id, Name), Contact(Id, Name)];
// DML — insert/update/delete/upsert all check object and field
// permissions for the running user before the operation executes
Account newAcc = new Account(Name = 'Test Account');
insert newAcc; // fails if running user lacks Create access on Account
Explicitly Choosing an Access Mode
If you genuinely need system-mode behavior for a specific operation a common and legitimate need for integration users, batch jobs, or trigger-invoked handlers that must process every record regardless of who triggered the transaction you now opt in explicitly rather than relying on it being the default.
For SOQL/SOSL, use the WITH USER_MODE or WITH SYSTEM_MODE clause directly in the query:
// Explicit user mode (redundant at v67.0+, but useful for clarity
// and for code that must behave the same regardless of API version)
List<Account> accs = [
SELECT Id, Name FROM Account WHERE Rating = 'Hot' WITH USER_MODE
];
// Explicit system mode — opts OUT of the new default
List<Account> accs = [
SELECT Id, Name FROM Account WHERE Rating = 'Hot' WITH SYSTEM_MODE
];
For DML statements, use the as user or as system keywords:
Account a = [SELECT Id, Name FROM Account WHERE Support__c = 'Premier' WITH USER_MODE]; a.Rating = 'Hot'; update as user a; // explicit user mode update as system a; // explicit system mode — bypasses FLS/sharing for this statement
For Database class methods, set the accessLevel parameter to AccessLevel.USER_MODE or AccessLevel.SYSTEM_MODE:
// Database.query with explicit access level
Account a = Database.query(
'SELECT Id, Name FROM Account WHERE Rating = \'Hot\'',
AccessLevel.USER_MODE
);
// Database.insert with explicit access level
Database.SaveResult sr = Database.insert(newAccount, AccessLevel.SYSTEM_MODE);
// Database.update, Database.delete, Database.upsert all accept
// the same accessLevel parameter
Database.SaveResult[] results = Database.update(accountList, AccessLevel.USER_MODE);
The Sharing Interaction Nuance
One subtlety that trips people up: user mode and system mode aren’t purely symmetric when it comes to sharing rules specifically (as opposed to FLS/CRUD).
- If you set a database operation to user mode explicitly, it respects the user’s sharing rules and actually overrides the calling class’s own sharing declaration. So even if the class is declared
without sharing, a query executedWITH USER_MODEinside that class will still enforce sharing. - If you set the operation to system mode, then the sharing declaration of the calling class determines whether record-level (sharing) visibility is still respected. A
with sharingclass running a system-mode query will still respect sharing rules for record visibility, even though FLS and object permissions are bypassed.
This distinction matters most in mixed codebases where a class handles both trusted internal logic and user-facing logic in the same transaction. Get this wrong and you can end up in a situation where you think you’ve locked a query down with system mode, but the class-level sharing declaration is quietly still filtering rows or vice versa, where you assumed a without sharing class fully bypasses sharing, but an explicit user-mode query inside it doesn’t.
Versioning Scope
This is a versioned change it only applies to classes compiled at API v67.0 or later. Existing classes on v66 or earlier keep running in system mode exactly as before, even after your org itself is upgraded to Summer ’26. You control the blast radius by controlling which individual classes you bump to v67.0, which is the entire point of versioned changes Salesforce lets you migrate incrementally rather than forcing an org-wide flag day.
2. Apex Classes Enforce Sharing Rules by Default (with sharing)
The Old Behavior
The second half of the security shift is about sharing, not just field/object permissions a genuinely separate axis of access control. In API v66.0 and earlier, an Apex class with no explicit sharing keyword generally defaulted to without sharing (with some historical exceptions depending on context) meaning it ignored the running user’s sharing rules and could see records across the whole org regardless of role hierarchy, sharing rules, team membership, or manually granted shares.
// API v66 and earlier — no keyword means "without sharing" in most cases
public class GeneratorSyncHandler {
public void processGenerators() {
List<Account> allGenerators = [SELECT Id, Name FROM Account WHERE RecordType.Name = 'Generator'];
// Sees ALL Generator records, regardless of sharing rules,
// role hierarchy, or manual shares on individual accounts
}
}
The New Behavior
At API v67.0, the default flips to with sharing:
// API v67.0+ — no keyword now means "with sharing"
public class GeneratorSyncHandler {
public void processGenerators() {
List<Account> allGenerators = [SELECT Id, Name FROM Account WHERE RecordType.Name = 'Generator'];
// Now only sees records the running user has sharing access to,
// even though no keyword is declared in the class definition
}
}
If a class genuinely needs to bypass sharing a common and entirely legitimate pattern for backend integration processing, like syncing every Generator account regardless of who owns it or has been granted access to it you now have to say so explicitly:
public without sharing class GeneratorSyncHandler {
// Explicitly opts out of sharing enforcement — same as the v66 default behavior
}
The Three Sharing Keywords, and inherited sharing
It’s worth being precise about all the options here, since “no keyword” isn’t actually one of your real choices anymore it now just means with sharing:
// Always enforces sharing rules for the running user
public with sharing class AccountService { }
// Never enforces sharing rules — sees all records regardless of visibility
public without sharing class BatchSyncJob { }
// Inherits the sharing mode of whatever class called it. If called
// from a with sharing context, it behaves with sharing. If called
// from an unspecified context (e.g. anonymous Apex, a Queueable
// enqueued without an explicit sharing wrapper), it defaults to
// with sharing as of v67.0.
public inherited sharing class GeneratorValidationLogic { }
inherited sharing is particularly useful for utility and service classes meant to be reused across both with sharing and without sharing callers a validation or field-mapping helper class, for example, probably shouldn’t hard-code an opinion about sharing; it should just inherit whatever context it’s called from. This pattern was available before Summer ’26 too, but it becomes more valuable now that the unspecified default has changed, since inherited sharing classes calling into other unspecified-context code will now inherit with sharing behavior where they previously inherited without sharing.
Why This Matters More Than It Sounds
The practical risk here isn’t that your code breaks loudly it’s that it might not break at all, in the sense of throwing an exception. It just starts returning less data, silently. A batch job that used to process every Generator account might silently process a smaller subset once bumped to v67.0, if the running user (often an integration user, but not always some batch and queueable jobs run in the context of the user who scheduled or enqueued them) doesn’t have full sharing visibility across the org.
This is arguably more dangerous than a hard failure, because hard failures get noticed in testing. A quietly-smaller result set might pass every existing unit test (since test setup often creates data and runs as an admin-equivalent user with full visibility) and only manifest in production, weeks later, as “why did the nightly sync only update 40% of our Generator records last night?”
The good practice going forward, regardless of which mode you actually want for a given class, is to always declare sharing explicitly with sharing, without sharing, or inherited sharing rather than relying on a version-dependent default that silently changes meaning depending on which API version happens to be set on the class metadata. This is good hygiene independent of the Summer ’26 change, but it becomes essential once the default itself is a moving target across your codebase’s history.
The Trigger Exception
One exception worth flagging explicitly: Apex triggers always run in system mode, on any API version, and as of Summer ’26 you can no longer declare sharing or access-mode keywords directly on a trigger at all that was already true in practice for most trigger contexts, but it’s now formalized. If a trigger needs to enforce sharing or run in user mode, that logic has to move into a handler class where you control the execution context explicitly:
trigger GeneratorTrigger on Account (after update) {
// Triggers always run in system mode — no sharing keyword allowed here
GeneratorTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
}
public with sharing class GeneratorTriggerHandler {
// Sharing IS enforceable here, because this is a regular class,
// not the trigger body itself
public static void handleAfterUpdate(List<Account> newRecords, Map<Id, Account> oldMap) {
// logic here respects the with sharing declaration
}
}
This is exactly the kind of pattern most well-structured trigger frameworks already follow a thin trigger delegating immediately to a handler class so teams already using that architecture (which, if you’re maintaining a large Salesforce org, you almost certainly are) won’t need to restructure anything. Teams with logic still living directly inside trigger bodies will need to extract it regardless of whether they care about sharing enforcement, simply because the compiler will no longer accept sharing keywords on the trigger itself in some contexts going forward.
3. The WITH SECURITY_ENFORCED SOQL Clause Is Removed
What Breaks
If your team has leaned on WITH SECURITY_ENFORCED over the years to enforce FLS in SOQL and most mature Apex codebases have, since it was the standard recommended pattern for years this is the change that will actually stop your code from compiling, not just change its behavior silently like items 1 and 2 above. Apex classes set to API version 67.0 or later that still contain WITH SECURITY_ENFORCED simply won’t compile.
// API v66 and earlier — compiles fine
List<Account> accs = [
SELECT Id, Name, AnnualRevenue
FROM Account
WHERE Support__c = 'Premier'
WITH SECURITY_ENFORCED
];
// API v67.0+ — this line now causes a compile error
List<Account> accs = [
SELECT Id, Name, AnnualRevenue
FROM Account
WHERE Support__c = 'Premier'
WITH SECURITY_ENFORCED // will not compile at v67.0+
];
Because this is a compile-time failure rather than a runtime behavior change, it’s actually the easiest of the three security changes to catch your CI pipeline or a simple deployment attempt will surface it immediately, rather than requiring careful behavioral testing to discover. The tradeoff is that it will block your deployment entirely if you bump a class’s API version without addressing it first, so it’s worth clearing out proactively rather than discovering it mid-deployment-window.
The Replacement: WITH USER_MODE
// API v67.0+ — correct replacement
List<Account> accs = [
SELECT Id, Name, AnnualRevenue
FROM Account
WHERE Support__c = 'Premier'
WITH USER_MODE
];
This isn’t just a rename with a new keyword WITH USER_MODE is meaningfully more capable than what it replaces, in ways that matter for real production debugging:
- It correctly handles polymorphic fields, like
OwnerorTask.WhatId, whichWITH SECURITY_ENFORCEDnever fully supported and would sometimes silently skip enforcing. - It evaluates the entire query, including the
WHEREclause and any subqueries, not just the fields listed in theSELECTclause.WITH SECURITY_ENFORCEDhistorically only checked SELECT-list fields, which meant a filter condition referencing a field the user couldn’t see wasn’t actually protected. - It surfaces every FLS violation in the query at once, whereas
WITH SECURITY_ENFORCEDonly ever surfaced the first error it encountered meaning developers debugging an access issue would fix one field, redeploy, hit the next violation, fix that, redeploy again, and so on. - In user mode, you can call
getInaccessibleFields()on the resultingQueryExceptionto inspect the complete set of access errors in one pass, rather than iterating through a fix-recompile-fail cycle.
try {
List<Account> accs = [
SELECT Id, Name, Sensitive_Field__c, Owner.Name
FROM Account
WHERE Rating = 'Hot'
WITH USER_MODE
];
} catch (System.QueryException e) {
// Inspect the full set of fields the user couldn't access,
// instead of only the first one — genuinely useful for
// building a clear error message back to the calling context
List<String> inaccessible = e.getInaccessibleFields();
System.debug('Inaccessible fields: ' + inaccessible);
// Example: surface a friendlier message in an LWC-facing
// AuraHandledException rather than the raw platform exception
throw new AuraHandledException(
'You do not have access to: ' + String.join(inaccessible, ', ')
);
}
Handling the Alternative: Security.stripInaccessible()
It’s worth distinguishing WITH USER_MODE from another existing security tool that is not going away: Security.stripInaccessible(). These solve a similar problem with different failure philosophies:
// WITH USER_MODE — strict enforcement, throws an exception on violation
List<Account> accs = [SELECT Id, Sensitive_Field__c FROM Account WITH USER_MODE];
// stripInaccessible — graceful degradation, removes inaccessible
// fields from the result set and lets execution continue normally
List<Account> rawAccs = [SELECT Id, Sensitive_Field__c FROM Account];
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.READABLE, rawAccs
);
List<Account> safeAccs = decision.getRecords();
For most new code, start with WITH USER_MODE/user mode as your default it’s stricter, it’s the platform’s own new default behavior, and failing loudly on an access violation is usually the right instinct for security-sensitive code. Reach for stripInaccessible() specifically when your design genuinely calls for partial results or a controlled fallback rather than a hard failure for example, a search results page that should show whatever fields the user can see rather than blocking the entire result set because of one restricted field.
Auditing for This Change
If you’re doing a codebase-wide audit ahead of a v67.0 migration, WITH SECURITY_ENFORCED is one of the easiest things to search for programmatically:
# From a local clone of your metadata, a simple grep gets you most of the way grep -rn "WITH SECURITY_ENFORCED" force-app/main/default/classes/
Every instance needs to become WITH USER_MODE (or an explicit WITH SYSTEM_MODE if that specific query genuinely needs elevated access for example, a lookup query that’s checking for record existence across the org regardless of the current user’s visibility) before those classes can be safely bumped to API v67.0. Because this is a compile-time issue, you don’t need production data or user context to catch it a sandbox deployment attempt will tell you immediately which classes still need fixing.
4. Work with Apex Code in Web Console (Beta)
This one’s a tooling addition rather than a behavior change, but it’s genuinely useful groundwork while you’re doing the kind of careful, iterative testing that a v67.0 migration demands. Web Console is a new client-side IDE embedded directly in Salesforce Setup, letting you build and run SOQL queries, configure trace flags and debug log levels, and execute anonymous Apex all without leaving the browser or switching to a separate tool like Developer Console or Workbench.
What It Offers
- SOQL query building directly in the console, without needing to construct and re-run queries through Developer Console’s separate query editor.
- Trace flag and debug log level configuration inline, so you can turn up logging verbosity for a specific user or class right where you’re already working.
- Anonymous Apex execution, which is exactly the tool you want for quick sanity checks while validating v67.0 behavior for example, running a query
WITH USER_MODEas a specific user context to confirm what data they can and can’t see, without needing to deploy a full test class first.
How It Fits Into Your Existing Workflow
A detail that makes this more than a novelty: once enabled, Web Console opens automatically whenever you navigate into the Apex Classes, Apex Triggers, or Apex Jobs pages in Setup. That means it slots into a workflow you’re already using rather than requiring you to remember a separate destination or bookmark if you’re already in Setup looking at a class that needs updating for the v67.0 migration, Web Console is right there.
How to Enable It
- In Setup, enter Development in the Quick Find box.
- Select Web Console (Beta).
- Turn on Enable Web Console (Beta).
- Refresh your browser window.
- Web Console now appears as a dropdown item in the Setup menu, and opens automatically from the Apex Classes/Triggers/Jobs pages.
It’s available to all orgs at no additional cost, which lowers the bar for just turning it on in a sandbox and trying it during your migration testing rather than waiting for it to reach general availability. That said, it is beta validate carefully before leaning on it for anything genuinely production-critical, and don’t treat it as a replacement for proper unit test coverage, just as a faster feedback loop while you’re actively debugging.
Practical Use During a v67.0 Migration
This is worth calling out specifically because it’s the most directly relevant use case for everything else in this post: while you’re auditing classes for sharing declarations and user-mode behavior, Web Console lets you quickly run a query as anonymous Apex and immediately see whether a specific user role can access the records or fields you expect, without writing and deploying a full test class for every single check. That tight feedback loop is genuinely valuable when you’re working through dozens of classes during a migration window.
5. Visible No-Argument Constructors for Invocable Apex Classes
This is the change most likely to break something quietly if you’re not actively looking for it, because it affects Flow-Apex integration rather than Apex behavior in isolation and unlike the security changes above, it fails at runtime, not compile time, which makes it easy to miss in code review.
What’s Required
Starting at API version 66.0 (enforcement was scheduled and then postponed more than once before finally landing tied to Summer ’26), any custom Apex class used as an invocable action parameter meaning a class referenced by @InvocableMethod or @InvocableVariable when a Flow calls into Apex must expose a visible no-argument constructor. Public, for classes outside a managed package. Global, for classes invoked from outside their own managed package.
Why This Breaks Silently
The problem is subtle and specifically Apex-flavored: defining any custom constructor removes the compiler-generated default no-argument constructor. This is standard object-oriented language behavior, not a Salesforce quirk, but it interacts badly with how Flow instantiates invocable input classes. If your invocable input class has a parameterized constructor added for convenience elsewhere in your code a very common and previously totally harmless pattern and no explicit no-arg constructor alongside it, the class will fail to instantiate at runtime when Flow tries to build an instance of it to pass data in. Not at compile time. Not during static analysis. At the exact moment a live Flow tries to invoke your Apex action.
// This class will FAIL when used as an invocable action parameter
// at API v66.0+, because the custom constructor below silently
// deleted the implicit default no-arg constructor.
public class GeneratorSyncRequest {
@InvocableVariable
public String generatorId;
@InvocableVariable
public String status;
// Custom constructor added for convenience in other Apex code —
// this silently deletes the default no-arg constructor
public GeneratorSyncRequest(String generatorId, String status) {
this.generatorId = generatorId;
this.status = status;
}
}
public class GeneratorSyncAction {
@InvocableMethod(label='Sync Generator to MDM')
public static void syncGenerators(List<GeneratorSyncRequest> requests) {
// Flow attempts to instantiate GeneratorSyncRequest with no
// arguments to populate it via @InvocableVariable fields —
// this throws at runtime because no no-arg constructor exists
}
}
The Fix
Add an explicit no-argument constructor alongside the parameterized one the parameterized constructor can stay, for use anywhere else in your codebase that constructs this class directly:
public class GeneratorSyncRequest {
@InvocableVariable
public String generatorId;
@InvocableVariable
public String status;
// Explicit no-arg constructor — required for Flow to instantiate
// this class as an invocable action parameter
public GeneratorSyncRequest() {}
// Existing parameterized constructor — still fine to keep and use
// elsewhere in your own Apex code
public GeneratorSyncRequest(String generatorId, String status) {
this.generatorId = generatorId;
this.status = status;
}
}
A Note on the Release History
A bit of context is useful here, because this update has changed names and enforcement dates more than once, which makes it easy to have half-remembered, outdated information about it. It was originally announced under the name “Enforce Permission Requirements Defined on Built-In Apex Classes Used as Inputs,” which described a related but not identical concern about permission requirements on certain built-in Apex classes used as invocable inputs. It was later renamed to “Enforcing No-Argument Constructor on Apex Classes Used for Invocable Action Parameters,” and its enforcement date moved from an earlier release out to Summer ’26. Even where the release update itself is not actively enabled in an org, the underlying versioned change still applies automatically to any class compiled at API v66.0 or later so disabling the release update does not by itself protect classes that are already on v66.0+.
How to Find Affected Classes Before They Find You
The practical audit here is straightforward but requires checking two conditions together, not just one:
- Search your codebase for any class carrying
@InvocableVariablefields, or referenced as a parameter type on a method annotated@InvocableMethod. - For each such class, check whether it defines any constructor at all.
- If it does define a constructor, confirm there’s also an explicit no-argument constructor alongside it. If there isn’t, that class will fail the next time Flow tries to instantiate it.
# A starting point for locating candidate classes — this needs manual # follow-up since it can't fully verify constructor visibility on its own grep -rln "@InvocableVariable\|@InvocableMethod" force-app/main/default/classes/
This is worth fixing proactively across your org rather than waiting for a support ticket about a Flow that “just stopped working” especially since the failure surfaces as a runtime error inside the Flow’s fault path (if one exists) or a hard failure with no fault path (if one doesn’t), which can be a confusing debugging experience for whoever picks up that ticket without knowing this change exists.
6. Testing Strategy for All of the Above
Given that items 1 and 2 change behavior silently rather than failing loudly, testing strategy matters more here than for a typical release. A few concrete practices:
Use System.runAs() to Simulate Real User Contexts
Your existing unit tests likely run largely in a system-administrator-equivalent context by default, which means they may never actually exercise the FLS, sharing, and object-permission boundaries that real end users hit. Wrap key assertions in System.runAs() with a test user assigned a restrictive profile or permission set to actually validate the new default behavior:
@isTest
static void testGeneratorSyncRespectsUserMode() {
User restrictedUser = createTestUserWithLimitedAccess();
Test.startTest();
System.runAs(restrictedUser) {
List<Account> results = GeneratorSyncHandler.getGeneratorsForSync();
// Assert that fields/records this user shouldn't see are
// correctly excluded or that an appropriate exception is thrown
System.assertEquals(expectedFilteredCount, results.size());
}
Test.stopTest();
}
Test Both the Integration User and the Standard End-User Path
If your Apex is invoked both by scheduled/batch jobs (usually running as an integration or automated-process user with broad access) and by interactive Flow or LWC actions (running as the logged-in end user), test both paths explicitly rather than assuming the broader-access path represents your whole test surface. A test suite that only exercises the integration-user path can pass entirely while the end-user path silently breaks in production.
Add Explicit Negative Tests for FLS and Sharing
Rather than only testing the happy path where a user can see everything, add tests that specifically assert an access-restricted user cannot see fields or records they shouldn’t, and that this failure mode is handled gracefully (a clear error message, a filtered result set, or an appropriate exception type) rather than an unhandled exception bubbling up to the end user.
Regression-Test the Invocable Classes Directly From Flow, Not Just From Apex Test Classes
Because the no-arg constructor issue is specifically about how Flow instantiates a class not how your own Apex code instantiates it a unit test that only calls the invocable method directly from Apex (bypassing Flow’s own instantiation mechanism) may not catch the failure. Where possible, validate invocable actions by actually running the Flow itself in a sandbox, not only via @isTest methods that call the underlying Apex method signature directly.
7. A Real-World Migration Walkthrough
To make this concrete, here’s how a typical migration might look for an integration-heavy class something structurally similar to a Generator or Account sync handler built against Oracle MDM.
Step 1 – Audit the class at its current API version.
// Before: no sharing keyword, uses WITH SECURITY_ENFORCED, API v58.0
public class GeneratorMDMSyncService {
public List<Account> getGeneratorsForSync() {
return [
SELECT Id, Name, EPA_ID__c, Status__c
FROM Account
WHERE RecordType.Name = 'Generator'
WITH SECURITY_ENFORCED
];
}
}
Step 2 – Decide the intended sharing behavior explicitly, rather than relying on either version’s default. This integration service is meant to see every Generator record regardless of ownership, so without sharing is the correct explicit declaration:
public without sharing class GeneratorMDMSyncService {
public List<Account> getGeneratorsForSync() {
return [
SELECT Id, Name, EPA_ID__c, Status__c
FROM Account
WHERE RecordType.Name = 'Generator'
WITH SYSTEM_MODE
];
}
}
Step 3 – Replace WITH SECURITY_ENFORCED with the appropriate explicit mode. Since this service needs to see all Generator data regardless of any individual user’s FLS restrictions (it’s a backend sync process, not an end-user-facing query), WITH SYSTEM_MODE paired with the explicit without sharing class declaration from Step 2 correctly documents that intent instead of leaving it as an implicit, version-dependent default.
Step 4 – Bump the API version to 67.0 only after Steps 2 and 3 are in place and tested, not before. Bumping the version first, then trying to fix compile errors under time pressure, is a worse sequencing than fixing the code to be version-agnostic first.
Step 5 – Write or update tests using System.runAs() with both a restricted standard user and the integration user context, confirming the service still returns the full Generator dataset regardless of which user context triggers it which is exactly the behavior the explicit without sharing / WITH SYSTEM_MODE combination is meant to guarantee, independent of API version defaults going forward.
This same five-step pattern audit, decide intended behavior explicitly, replace deprecated syntax, bump version, test both contexts applies to essentially every class you’ll touch during this migration, whether it’s an integration service, a Flow-invoked action, or an LWC-facing controller.
8. Common Pitfalls and Edge Cases
- Assuming a passing test suite means the migration is safe. As covered above, test suites that run entirely in an elevated context won’t catch sharing or FLS regressions. Passing tests are necessary but not sufficient evidence here.
- Bumping API version org-wide in one deployment. Because these are versioned changes, there’s no requirement to move every class to v67.0 simultaneously. Doing so removes your ability to isolate which specific class caused a production issue if something goes wrong.
- Forgetting that
without sharingdoesn’t fully bypass FLS. Sharing and FLS/CRUD are separate axes of access control. Awithout sharingclass at v67.0 still enforces field-level security and object permissions by default (via user-mode database operations) unless you also explicitly set the relevant queries or DML to system mode. - Missing constructor issues because they only fail inside Flow. As noted in Section 5, an Apex-only test suite can pass completely while the actual Flow integration fails, since the failure is specific to how Flow instantiates the class.
- Confusing the class-level sharing keyword with the query-level access mode. These interact (see the nuance in Section 1) but they aren’t the same setting, and getting the interaction wrong is one of the easier mistakes to make when refactoring a class that mixes both concerns.
- Treating Web Console (Beta) as production-hardened. It’s a genuinely useful tool for iterative testing, but it’s still beta don’t build a dependency on it for anything that needs guaranteed availability or long-term stability commitments.
9. Migration Checklist and Rollout Sequencing
None of these five changes force you to act the moment Summer ’26 lands items 1 through 3 are versioned changes tied to API v67.0, so existing classes keep their current behavior until you actually bump the version. But since you can’t defer that upgrade indefinitely, a reasonable sequence looks like this:
- Audit first, upgrade second. Search for every use of
WITH SECURITY_ENFORCEDand every class missing an explicit sharing declaration before touching API versions on any class. - Fix invocable classes regardless of your API version migration timeline, since the no-arg constructor requirement is tied to v66.0, which is likely already in use across parts of your org today, independent of the v67.0 security changes.
- Decide and document the intended sharing/access behavior for each class explicitly, rather than defaulting to whatever the old or new version happens to imply.
- Bump non-critical classes to v67.0 first, test thoroughly in a sandbox, and only then move critical, data-sensitive classes don’t do a blanket org-wide version bump in one pass.
- Test with both integration users and standard end users, using
System.runAs()and, where possible, actually running affected Flows rather than only calling Apex methods directly in tests. - Turn on Web Console (Beta) in a sandbox to make ad-hoc verification of these changes faster while you work through the audit.
- Re-audit after go-live, not just before silent data-visibility regressions are more likely to surface in production usage patterns than in a QA pass with a limited set of test users.
10. FAQ
Does upgrading my org to Summer ’26 automatically change how my existing Apex behaves? No. Items 1–3 are versioned changes tied specifically to API version 67.0 on individual classes. Existing classes compiled at v66.0 or earlier keep their current behavior even after the org itself is on Summer ’26. You control this class by class.
Do I have to fix every class before I can upgrade to Summer ’26 at all? No – the org-level Summer ’26 upgrade and the per-class API version bump are separate things. You can be running Summer ’26 as your release while still having classes compiled at older API versions with unchanged behavior.
Is the invocable no-arg constructor rule also versioned the same way? Yes, it’s tied to API v66.0 specifically – so if parts of your org are already on v66.0 or later (which is common, since v66.0 predates Summer ’26), those classes need this fix regardless of your plans around the v67.0 security changes.
What happens if I forget to fix WITH SECURITY_ENFORCED before bumping a class to v67.0? The class simply won’t compile, and you’ll find out immediately during deployment – this is the easiest of the changes to catch precisely because it fails loudly at compile time rather than silently at runtime.
Should I use WITH USER_MODE everywhere now, even where I don’t strictly need it? For new code, yes being explicit about access mode, even when it matches the default, makes intent clear to future readers and keeps behavior consistent if defaults ever change again in a future release.
Conclusion
Summer ’26 is the release where Salesforce stops asking developers to remember to secure their own Apex and starts doing it by default. That’s a genuinely good direction for the platform’s long-term security posture, but it’s also exactly the kind of change that can surface as a confusing support ticket three sprints from now if nobody proactively audits for it today – precisely because two of the three security changes fail silently rather than loudly. The invocable constructor rule is smaller in scope but similarly sneaky, since it fails at runtime inside a Flow rather than at Apex compile time.
Treat all of this as planned migration work with a real checklist and sequencing, not a footnote in the release notes to skim past. A few hours of searching your codebase now – for WITH SECURITY_ENFORCED, for classes without explicit sharing declarations, and for invocable classes missing a no-arg constructor is a lot cheaper than debugging a Flow that mysteriously stopped working, or an integration job that’s quietly returning fewer records than it used to, weeks after the fact.
