
Platform Apex Generate
- 2.5k installs
- 763 repo stars
- Updated July 24, 2026
- forcedotcom/sf-skills
Generates production-grade Salesforce Apex classes, triggers, and async jobs following enterprise Service-Selector-Domain patterns, with code-analyzer validation.
About
platform-apex-generate is a Salesforce Apex code generator that authors production-grade classes, triggers, and async jobs following enterprise Service-Selector-Domain architecture. It runs the Apex code analyzer, remediates governor-limit and security issues, and enforces org conventions like sharing keywords and bulkification. A solo developer on the Salesforce platform reaches for it when scaffolding new Apex or refactoring existing .cls/.trigger files without hand-writing boilerplate.
- Enterprise Service-Selector-Domain layering
- Batch/Queueable/Schedulable + Finalizer jobs
- Runs code analyzer with sev0/1/2 remediation
- Blocks SOQL-in-loops and missing sharing keywords
Platform Apex Generate by the numbers
- 2,472 all-time installs (skills.sh)
- +648 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #207 of 4,386 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/forcedotcom/sf-skills --skill platform-apex-generateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.5k |
|---|---|
| repo stars | ★ 763 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
What it does
Generates production-grade Salesforce Apex classes, triggers, and async jobs following enterprise Service-Selector-Domain patterns, with code-analyzer validation.
Who is it for?
Salesforce developers scaffolding enterprise-grade Apex fast
Skip if: Non-Salesforce backends
When should I use this skill?
Creating or refactoring Apex classes, triggers, or async jobs
What you get
- apex classes
- apex triggers
- async jobs
Files
Generating Apex
Use this skill for production-grade Apex: new classes, selectors, services, async jobs, invocable methods, and triggers; and for evidence-based review of existing .cls OR .trigger.
Required Inputs
Gather or infer before authoring:
- Class type (service, selector, domain, batch, queueable, schedulable, invocable, trigger, trigger action, DTO, utility, interface, abstract, exception, REST resource)
- Target object(s) and business goal
- Class name (derive using the naming table below)
- Net-new vs refactor/fix; any org/API constraints
- Deployment targets (default to runSpecifiedTests and use generated tests where applicable)
Defaults unless specified:
- Sharing:
with sharing(see sharing rules per type below) - Access:
public(useglobalonly when required by managed packages or@RestResource) - API version:
66.0(minimum version) - ApexDoc comments: yes
If the user provides a clear, complete request, generate immediately without unnecessary back-and-forth.
---
Workflow
All steps are sequential. Do not skip, merge, or reorder. If blocked, stop and ask for missing context. If not applicable, mark N/A with a one-line justification in the report.
Phase 1 — Author
1. Discover project conventions
- Service-Selector-Domain layering, logging utilities
- Existing classes/triggers and current trigger framework or handler pattern
- Whether Trigger Actions Framework (TAF) is already in use
2. Choose the smallest correct pattern (see Type-Specific Guidance below)
3. Review templates and assets
- Read the matching template from
assets/before authoring (see Type-Specific Guidance for the file mapping) - When a
references/example exists for the type, read it as a concrete style guide - For any test class work, always read and use
platform-apex-test-generateskill
4. Author with guardrails -- apply every rule in the Rules section below
- Generate
{ClassName}.clswith ApexDoc - Generate
{ClassName}.cls-meta.xml
5. Generate test classes -- Load the skill platform-apex-test-generate to create {ClassName}Test.cls and {ClassName}Test.cls-meta.xml. Apex tests are always required to be generated to deploy. No test file creation or edits can occur without loading the platform-apex-test-generate skill to generate tests.
Phase 2 — Validate (required before reporting)
Writing files is the midpoint, not the finish line. Steps 6 and 7 each require a tool invocation and produce output that must appear in the Step 8 report. Do not summarize or present the report until both steps have run and their output is captured.
6. Run code analyzer
- Invoke MCP
run_code_analyzeron all generated/updated.clsfiles. - Remediate all
sev0,sev1, andsev2violations; re-run until clean. - Capture the final tool output verbatim for the report.
- Fallback:
sf code-analyzer run --target <target>. If both are unavailable, recordrun_code_analyzer=unavailable: <error>in the report.
7. Execute Apex tests
- Run org tests including
{ClassName}Testviasf apex run testor MCP. - Delegate all test generation/fixes/coverage work to
platform-apex-test-generate; iterate until the tests pass. - Capture pass/fail counts and coverage percentage for the report.
- If unavailable, record
test_execution=unavailable: <error>in the report.
Phase 3 — Report
8. Report -- use the output format at the bottom of this file.
- The
Analyzerline must contain the actual Step 6 tool output (orrun_code_analyzer=unavailable: <reason>after attempting invocation). - The
Testingline must contain the actual Step 7 results (ortest_execution=unavailable: <reason>after attempting invocation). - A report missing either line is incomplete. Always attempt the tool invocation before recording unavailable.
---
Rules
Hard-Stop Constraints (Must Enforce)
If any constraint would be violated in generated code, stop and explain the problem before proceeding:
| Constraint | Rationale |
|---|---|
| Place all SOQL outside loops | Avoid query governor limits (100 queries) |
| Place all DML outside loops | Avoid DML governor limits (150 statements) |
| Declare a sharing keyword on every class | Prevent unintended without sharing defaults and data exposure |
| Use Custom Metadata/Labels/describe calls instead of hardcoded IDs | Ensure portability across orgs |
| Always handle exceptions (log, rethrow, or recover) | Prevent silent failures |
| Use bind variables for all dynamic SOQL with user input | Prevent SOQL injection |
Use Apex-native collections (List, Map, Set) rather than Java types | Prevent compile errors |
| Verify methods exist in Apex before use | Prevent reliance on non-existent APIs |
Avoid System.debug() in main code paths | Debug statements evaluate even when loggign is not active and consume CPU. Use a logging framework if required on main code paths |
Never use @future methods | Use Queueable with System.Finalizer; @future cannot chain, cannot be called from Batch, and cannot accept non-primitive types |
Bulkification & Governor Limits
- All public APIs accept and process collections; single-record overloads delegate to the bulk method
- In batch/bulk flows, prefer partial-success DML (
Database.update(records, false)) and processSaveResultfor errors - Use
Map<Id, SObject>constructor for efficient ID-based lookups from query results - Use
Map<Id, List<SObject>>to group child records by parent; build the map in a single loop before processing - Use
Set<Id>for deduplication and membership checks; preferSet.contains()overList.contains() - Use relationship subqueries to fetch parent + child records in a single SOQL when both are needed
- Use
AggregateResultwithGROUP BYfor rollup calculations instead of querying and counting in Apex - Only DML records that actually changed — compare against
Trigger.oldMapor prior state before adding to the update list - Use
Limits.getQueries(),Limits.getDmlStatements(),Limits.getCpuTime()to monitor consumption in complex transactions
SOQL Optimization
- Use selective queries with proper
WHEREclauses; use indexed fields (Id,Name,OwnerId, lookup/master-detail fields,ExternalIdfields, custom indexes) in filters when possible SELECT *does not exist in SOQL -- always specify the exact fields needed- Apply
LIMITclauses to bound result sets; useORDER BYfor deterministic results - When querying Custom Metadata Types (objects ending with
__mdt), do NOT use SOQL — use the built-in methods ({CustomMdt__mdt}.getAll().values(),getInstance(), etc.)
Caching
- Use Platform Cache (
Cache.Org/Cache.Session) for frequently accessed, rarely changed data; set a TTL and always handle cache misses — cache can be evicted at any time - Use
private static Mapfields as transaction-scoped caches to prevent duplicate queries within the same execution context; lazy-initialize on first access
Security
- Default to
with sharing; document justification forwithout sharingorinherited sharing WITH USER_MODEin SOQL andAccessLevel.USER_MODEforDatabaseDML for CRUD/FLS enforcement- Validate dynamic field/operator names via allowlist or
Schema.describe - Named Credentials for all external credentials/API keys
AuraHandledExceptionfor@AuraEnableduser-facing errors (no internal details)without sharingrequires a Custom Permission check- Isolate
without sharinglogic in dedicated helper classes; call fromwith sharingentry points to limit elevated-access scope - Encrypt PII/sensitive data at rest via Platform Encryption; never expose PII in debug statements, error messages, or API responses
Security Verification
Before finalizing, verify: CRUD/FLS enforced (SOQL + DML) · explicit sharing keyword on every class · no hardcoded secrets or Record IDs · PII excluded from logs and error messages · error messages sanitized for end users.
Error Handling
- Catch specific exceptions before generic
Exception; include context in messages - Use
try/catchonly around code that can throw (DML, callouts, JSON parsing, casts); avoid defensive wrapping of simple assignments/collection ops/arithmetic - Preserve exception cause chains:
new CustomException('message', cause)(do not replace stack trace with concatenated messages) - Provide a custom exception class per service domain when meaningful
- In
@AuraEnabledmethods, catch exceptions and rethrow asAuraHandledException - Fallback option: when no meaningful domain exception exists, catch generic
Exceptionand either rethrow it or wrap it in a minimal custom exception that preserves the original cause.
Null Safety
- Add guard clauses for null/empty inputs at the top of every public method; match style to context:
returnearly in private/trigger-handler methods,throwexceptions in public APIs,record.addError()in validation services - Return empty collections instead of
null - Use safe navigation (
?.) for chained property access - Never dereference
map.get(key)inline unless presence is guaranteed; usecontainsKey, assignment+null check, or safe navigation first - Use null coalescing (
??) for default values - Prefer
String.isBlank(value)over manual checks likevalue == null || value.trim().isEmpty()
Constants & Literals
- Use enums over string constants whenever possible; enum values follow
UPPER_SNAKE_CASE - Extract repeated literal strings/numbers into
private static finalconstants or a constants class - Use
Label.custom labels for user-facing strings - Use Custom Metadata for configurable values (thresholds, mappings, feature flags)
- Never output HTML-escaped entities in code (e.g.,
'); use literal single quotes'in Apex string literals
Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Service | {SObject}Service | AccountService |
| Selector | {SObject}Selector | AccountSelector |
| Domain | {SObject}Domain | OpportunityDomain |
| Batch | {Descriptive}Batch | AccountDeduplicationBatch |
| Queueable | {Descriptive}Queueable | ExternalSyncQueueable |
| Schedulable | {Descriptive}Schedulable | DailyCleanupSchedulable |
| DTO | {Descriptive}DTO | AccountMergeRequestDTO |
| Wrapper | {Descriptive}Wrapper | OpportunityLineWrapper |
| Utility | {Descriptive}Util | StringUtil |
| Interface | I{Descriptive} | INotificationService |
| Abstract | Abstract{Descriptive} | AbstractIntegrationService |
| Exception | {Descriptive}Exception | AccountServiceException |
| REST Resource | {SObject}RestResource | AccountRestResource |
| Trigger | {SObject}Trigger | AccountTrigger |
| Trigger Action | TA_{SObject}_{Action} | TA_Account_SetDefaults |
Additional naming rules:
- Classes:
PascalCase - Methods:
camelCase, start with a verb (get,create,process,validate,is,has,can) - Variables:
camelCase, descriptive nouns; Lists as plural nouns (e.g.,accounts,relatedContacts); Maps as{value}By{key}(e.g.,accountsById); Sets as{noun}Ids - Constants:
UPPER_SNAKE_CASE - Use full descriptive names instead of abbreviations (
acc,tks,rec)
ApexDoc
- Required on the class header and every
public/globalmethod - Include: brief description,
@param,@return,@throws,@examplewhere helpful
Class-level format:
/**
* Provides services for geolocation and address conversion.
*/
public with sharing class GeolocationService { }Method-level format:
/**
* @param paramName Description of the parameter
* @return Description of the return value
* @example
* List<Account> results = AccountService.deduplicateAccounts(accountIds);
*/Code Structure & Architecture
- Single responsibility per class; max 500 lines -- split when exceeded
- Return Early: validate preconditions at method top, return/throw immediately
- Extract private helpers for methods over ~40 lines
- Use Dependency Injection (constructor/method params) for testability
- Prefer composition and narrow interfaces over deep inheritance; extend via new implementations, not modifications
- Enforce single-level abstraction per method across layer boundaries:
| Layer | Owns | Must NOT contain |
|---|---|---|
| Trigger | Event routing only | Business logic, orchestration |
| Handler/Service | Flow control, coordination | Inline SOQL/DML/HTTP/parsing |
| Domain | Business rules, validation | Queries, callouts, persistence details |
| Data/Integration | SOQL, DML, HTTP | Business decisions |
- Disallowed: methods mixing orchestration with inline SOQL/DML/HTTP; business rules mixed with parsing internals; validation + persistence + cross-system plumbing in one method
---
Async Decision Matrix
| Scenario | Default | Key Traits |
|---|---|---|
| Standard async work | Queueable | Job ID, chaining, non-primitive types, configurable delay (up to 10 min via AsyncOptions), dedup signatures |
| Very large datasets | Batch Apex | Chunked processing, max 5 concurrent; use QueryLocator for large scopes |
| Modern batch alternative | CursorStep (Database.Cursor) | 2000-record chunks, higher throughput, no 5-job limit |
| Recurring schedule | Scheduled Flow (preferred) or Schedulable | Schedulable has 100-job limit; use only when chaining to Batch or needing complex Apex logic |
| Post-job cleanup | Finalizer (System.Finalizer) | Runs regardless of Queueable success/failure |
| Long-running callouts | Continuation | Up to 3 per transaction, 3 parallel |
| Delays > 10 minutes | System.scheduleBatch() | Schedule a Batch job at a specific future time |
| Legacy fire-and-forget | @future | Do not use in new code — see Hard-Stop Constraints; replace with Queueable + Finalizer |
---
Type-Specific Guidance
Service
- Template:
assets/service.cls· Reference:references/AccountService.cls with sharing; stateless — nopublicfields or mutable instance state; keep public APIs focused andstaticwhere reasonable- Delegate all SOQL to Selectors and SObject behavior to Domains
- Wrap business errors in a custom exception (e.g.,
AccountServiceException)
Selector
- Template:
assets/selector.cls· Reference:references/AccountSelector.cls inherited sharing; one per SObject or query domain- Return
List<SObject>orMap<Id, SObject>; use a shared base field list constant (no inline duplication) - Accept filter parameters; always include
WITH USER_MODE
Domain
- Template:
assets/domain.cls with sharing; encapsulate field defaults, derivations, and validations- Operate on in-memory lists only; no SOQL/DML (belongs in Services/Selectors)
Batch
- Template:
assets/batch.cls· Reference:references/AccountDeduplicationBatch.cls with sharing; implementDatabase.Batchable<SObject>(addDatabase.Statefulwhen tracking across chunks)start()= query definition;execute()= business logic;finish()= logging/notification- Use
QueryLocatorfor large datasets; handle partial failures viaDatabase.SaveResult - Accept filter parameters via constructor for reusability
Queueable
- Template:
assets/queueable.cls with sharing; implementQueueableand optionallyDatabase.AllowsCalloutswhen HTTP callouts are needed- Accept data via constructor
- Add chain-depth guards to prevent infinite chains
- Optionally implement
Finalizerfor recovery/cleanup - Use
AsyncOptionsfor configurable delay (up to 10 min) and dedup signatures
Schedulable
- Template:
assets/schedulable.cls with sharing;execute()delegates to Queueable or Batch- Provide CRON constants and a convenience
scheduleDaily()helper
DTO / Wrapper
- Template:
assets/dto.cls - No sharing keyword needed (pure data containers)
- Simple public properties; no-arg + parameterized constructors;
Comparablewhen ordering matters - Use
@JsonAccesson private/protected inner DTOs that are serialized/deserialized
Utility
- Template:
assets/utility.cls - No sharing keyword needed; all methods
public static;privateconstructor - Pure, side-effect-free; no SOQL/DML
Interface
- Template:
assets/interface.cls - Define clear contracts with ApexDoc on each method signature
Abstract
- Template:
assets/abstract.cls with sharing; offer default behavior viavirtualmethods- Mark extension points
protected virtualorprotected abstract - Include a concrete example in the ApexDoc showing how to extend the class
Custom Exception
- Template:
assets/exception.cls - No sharing keyword; extend
Exceptionwith descriptive names - Supported constructors:
(),('msg'),(cause),('msg', cause)
Trigger
- Template:
assets/trigger.cls - One trigger per object; delegate all logic to handler/TAF action classes
- Include all relevant DML contexts; if TAF:
new MetadataTriggerHandler().run();
Trigger Action (TAF)
- One class per concern per context; implement
TriggerAction.{Context} - Register via
Trigger_Action__mdt(actions are inactive without registration) - Name:
TA_{SObject}_{ActionName}; prefer field-value comparison over static booleans for recursion
Invocable Method (@InvocableMethod)
- Template:
assets/invocable.cls with sharing; innerRequest/Responsewith@InvocableVariable- Method must be
public static; non-static or single-object signatures will not compile - Accept
List<Request>, returnList<Response>; bulkify (SOQL/DML outside loops) - Decorator parameters:
label(required — Flow Builder display name),description,category(groups actions in Builder),callout=true(required when method makes HTTP callouts) @InvocableVariableparameters:label(required),description,required=true/false@InvocableVariablesupports: primitives,Id,SObject,List<T>only (noMap/Set/Blob); useList<Id>orList<SObject>fields for Flow collection I/O- Always include
isSuccess,errorMessage, anderrorType(e.getTypeName()) in Response - Return errors in Response (recommended); throwing an exception triggers the Flow Fault path — reserve for unrecoverable failures only
REST Resource (@RestResource)
- Template:
assets/rest-resource.cls global with sharing; both class and methods must beglobal- Versioned URL:
@RestResource(urlMapping='/{resource}/v1/*') - Use proper HTTP status codes per branch (
200/201/400/404/422/500); never default all errors to500 - Validate inputs (Id format:
Pattern.matches('[a-zA-Z0-9]{15,18}', value)); bind all user input in SOQL - Include
LIMIT/ORDER BYin queries; implement pagination (pageSize/offset) - Standardized
ApiResponsewrapper (success,message,data/records); inner request/response DTOs - Thin controller: delegate business logic to Service classes
@AuraEnabled Controller
with sharing; useWITH USER_MODEin all SOQL- Use
@AuraEnabled(cacheable=true)only for read-only queries; leavecacheableunset for DML operations - Catch exceptions and rethrow as
AuraHandledExceptionwith user-friendly messages
---
Output Expectations
Deliverables per class:
{ClassName}.cls{ClassName}.cls-meta.xml(default API version66.0or higher unless specified){ClassName}Test.cls(generated viaplatform-apex-test-generateskill){ClassName}Test.cls-meta.xml(generated viaplatform-apex-test-generateskill)
Deliverables per trigger:
{TriggerName}.trigger{TriggerName}.trigger-meta.xml(default API version66.0or higher unless specified)
Meta XML template:
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>{API_VERSION}</apiVersion>
<status>Active</status>
</ApexClass>Report in this order:
Apex work: <summary>
Files: <paths>
Design: <pattern / framework choices>
Workflow: all steps completed (1-8); any N/A justified
Risks: <security, bulkification, async, dependency notes>
Analyzer: <REQUIRED -- paste actual run_code_analyzer output or state "run_code_analyzer=unavailable: <reason>">
Testing: <REQUIRED -- paste actual test execution results (pass/fail, coverage) or state "test_execution=unavailable: <reason>">
Deploy: <dry-run or next step>---
Cross-Skill Integration
| Need | Delegate to |
|---|---|
| Apex tests / fix failures | platform-apex-test-generate skill |
| Describe objects/fields | metadata skill (if available) |
| Deploy to org | deploy skill (if available) |
| Flow calling Apex | Flow skill (if available) |
| LWC calling Apex | LWC skill (if available) |
---
Troubleshooting Boundary
This skill handles production .cls/.trigger/.apex issues only: compile/parse failures, deployment dependency errors, runtime governor-limit failures. For test execution, assertions, coverage, or sf apex run test failures, delegate to platform-apex-test-generate.
/**
<<<<<<< Updated upstream
* @description Abstract base class for {describe the family of classes this serves}.
=======
* Abstract base class for {describe the family of classes this serves}.
>>>>>>> Stashed changes
* Provides common behavior and defines extension points for subclasses.
* Subclasses must implement the abstract methods to provide specific behavior.
*
* @example
* // Extending this abstract class:
* public class SalesforceIntegrationService extends {ClassName} {
* protected override String getEndpoint() {
* return 'callout:Salesforce_API/services/data/v62.0';
* }
*
* protected override Map<String, String> getHeaders() {
* return new Map<String, String>{
* 'Content-Type' => 'application/json'
* };
* }
* }
*/
public abstract with sharing class {ClassName} {
// ─── Constants ───────────────────────────────────────────────────────
private static final Integer DEFAULT_TIMEOUT_MS = 30000;
// ─── Protected State ─────────────────────────────────────────────────
protected Integer timeoutMs;
// ─── Constructor ─────────────────────────────────────────────────────
/**
* Initializes the base class with default configuration
*/
protected {ClassName}() {
this.timeoutMs = DEFAULT_TIMEOUT_MS;
}
// ─── Abstract Methods (must be implemented by subclasses) ────────────
/**
* Returns the endpoint URL for this integration.
* Subclasses must provide their specific endpoint.
* @return The endpoint URL as a String
*/
protected abstract String getEndpoint();
/**
* Returns the HTTP headers for this integration.
* Subclasses define their own required headers.
* @return Map of header name to header value
*/
protected abstract Map<String, String> getHeaders();
// ─── Virtual Methods (can be overridden by subclasses) ───────────────
/**
* Hook called before the main operation executes.
* Override to add pre-processing logic.
* Default implementation does nothing.
* @param context Map of contextual data
*/
protected virtual void beforeExecute(Map<String, Object> context) {
// Default: no-op — override in subclass if needed
}
/**
* Hook called after the main operation completes.
* Override to add post-processing logic.
* Default implementation does nothing.
* @param context Map of contextual data
* @param result The result from the operation
*/
protected virtual void afterExecute(Map<String, Object> context, Object result) {
// Default: no-op — override in subclass if needed
}
// ─── Template Method (common workflow) ───────────────────────────────
/**
* Executes the operation using the template method pattern.
* Calls beforeExecute → doExecute → afterExecute in sequence.
* @param context Map of data needed for the operation
* @return The result of the operation
*/
public Object execute(Map<String, Object> context) {
beforeExecute(context);
Object result;
try {
result = doExecute(context);
} catch (Exception e) {
handleError(e);
throw e;
}
afterExecute(context, result);
return result;
}
// ─── Protected Helpers ───────────────────────────────────────────────
/**
* Core execution logic — override this for the main operation.
* Default implementation throws — subclass must provide implementation.
* @param context Map of data needed for the operation
* @return The result of the operation
*/
protected virtual Object doExecute(Map<String, Object> context) {
throw new UnsupportedOperationException(
'Subclass must override doExecute()'
);
}
/**
* Error handler called when doExecute throws.
* Override to customize error handling (e.g., logging, retry).
* @param e The exception that was thrown
*/
protected virtual void handleError(Exception e) {
System.debug(LoggingLevel.ERROR,
this.toString() + ' error: ' + e.getMessage() + '\n' + e.getStackTraceString()
);
}
// ─── Exception ───────────────────────────────────────────────────────
public class UnsupportedOperationException extends Exception {}
}
/**
* Batch Apex class for {describe the batch operation}.
* Processes {SObject} records in configurable batch sizes.
* Implements Database.Stateful to track cumulative results across chunks.
*
* @example
* // Execute with default batch size
* Database.executeBatch(new {ClassName}());
*
* // Execute with custom batch size
* Database.executeBatch(new {ClassName}(), 100);
*/
public with sharing class {ClassName} implements Database.Batchable<SObject>, Database.Stateful {
// ─── Constants ───────────────────────────────────────────────────────
private static final Integer DEFAULT_BATCH_SIZE = 200;
// ─── Stateful Tracking ───────────────────────────────────────────────
private Integer totalProcessed = 0;
private Integer totalErrors = 0;
private List<String> errorMessages = new List<String>();
// ─── Constructor ─────────────────────────────────────────────────────
/**
* Default constructor
*/
public {ClassName}() {
// Default configuration
}
// ─── Batchable Interface ─────────────────────────────────────────────
/**
* Defines the scope of records to process.
* Uses Database.QueryLocator for efficient large-dataset processing.
* @param bc The batch context
* @return QueryLocator for the records to process
*/
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name
// TODO: Add fields needed for processing
FROM {SObject}
// TODO: Add WHERE clause to scope the records
]);
}
/**
* Processes each batch of records.
* Uses Database.update with allOrNone=false for partial success handling.
* @param bc The batch context
* @param scope List of {SObject} records in the current batch
*/
public void execute(Database.BatchableContext bc, List<{SObject}> scope) {
List<{SObject}> recordsToUpdate = new List<{SObject}>();
for ({SObject} record : scope) {
// TODO: Apply business logic to each record
recordsToUpdate.add(record);
}
if (!recordsToUpdate.isEmpty()) {
List<Database.SaveResult> results = Database.update(recordsToUpdate, false);
processResults(results);
}
}
/**
* Performs post-processing after all batches complete.
* Logs a summary of the batch execution.
* @param bc The batch context
*/
public void finish(Database.BatchableContext bc) {
String summary = String.format(
'{0} completed. Processed: {1}, Errors: {2}',
new List<Object>{
{ClassName}.class.getName(),
totalProcessed,
totalErrors
}
);
System.debug(LoggingLevel.INFO, summary);
if (!errorMessages.isEmpty()) {
System.debug(LoggingLevel.ERROR, 'Error details: ' + String.join(errorMessages, '\n'));
}
// TODO: Send completion notification email or post to chatter if needed
}
// ─── Private Helpers ─────────────────────────────────────────────────
/**
* Processes Database.SaveResult list, tracking successes and failures
* @param results List of SaveResult from a DML operation
*/
private void processResults(List<Database.SaveResult> results) {
for (Database.SaveResult result : results) {
if (result.isSuccess()) {
totalProcessed++;
} else {
totalErrors++;
for (Database.Error err : result.getErrors()) {
errorMessages.add(
'Record ' + result.getId() + ': ' +
err.getStatusCode() + ' - ' + err.getMessage()
);
}
}
}
}
// ─── Static Helpers ──────────────────────────────────────────────────
/**
* Convenience method to execute with default batch size
* @return The batch job Id
*/
public static Id run() {
return Database.executeBatch(new {ClassName}(), DEFAULT_BATCH_SIZE);
}
}
/**
* Domain class for {SObject}.
* Encapsulates field-level defaults, derivations, and validations.
* Operates only on in-memory SObject data — no SOQL or DML.
*/
public with sharing class {SObject}Domain {
// ─── Constants ───────────────────────────────────────────────────────
// TODO: Add constants for default values, statuses, etc.
// ─── Field Defaults ──────────────────────────────────────────────────
/**
* Applies default field values to new {SObject} records.
* Call this before insert to ensure consistent defaults.
* @param records List of {SObject} records to apply defaults to
*/
public static void applyDefaults(List<{SObject}> records) {
if (records == null || records.isEmpty()) {
return;
}
for ({SObject} record : records) {
// TODO: Set default field values
// Example:
// if (record.Status__c == null) {
// record.Status__c = DEFAULT_STATUS;
// }
}
}
// ─── Derivations ────────────────────────────────────────────────────
/**
* Derives calculated field values based on other fields.
* Call this before insert and before update.
* @param records List of {SObject} records to derive values for
*/
public static void deriveFields(List<{SObject}> records) {
if (records == null || records.isEmpty()) {
return;
}
for ({SObject} record : records) {
// TODO: Derive calculated field values
// Example:
// record.FullAddress__c = buildFullAddress(record);
}
}
// ─── Validations ────────────────────────────────────────────────────
/**
* Validates {SObject} records and adds errors for any violations.
* Call this before insert and before update.
* @param records List of {SObject} records to validate
*/
public static void validate(List<{SObject}> records) {
if (records == null || records.isEmpty()) {
return;
}
for ({SObject} record : records) {
// TODO: Add validation rules
// Example:
// if (String.isBlank(record.Name)) {
// record.addError('Name is required.');
// }
}
}
// ─── Comparisons ────────────────────────────────────────────────────
/**
* Determines which fields have changed between old and new record versions.
* Useful in before update context.
* @param oldRecord The previous version of the record
* @param newRecord The current version of the record
* @return Set of field API names that have changed
*/
public static Set<String> getChangedFields({SObject} oldRecord, {SObject} newRecord) {
Set<String> changedFields = new Set<String>();
if (oldRecord == null || newRecord == null) {
return changedFields;
}
Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.{SObject}.fields.getMap();
for (String fieldName : fieldMap.keySet()) {
if (oldRecord.get(fieldName) != newRecord.get(fieldName)) {
changedFields.add(fieldName);
}
}
return changedFields;
}
// ─── Private Helpers ─────────────────────────────────────────────────
// TODO: Add private helper methods as needed
}
/**
* Data Transfer Object for {describe the data this DTO represents}.
* Used to pass structured data between layers without exposing SObjects.
* Serialization-friendly for use with JSON.serialize/deserialize and API responses.
*
* @example
* // Create from constructor
* {ClassName} dto = new {ClassName}('value1', 42);
*
* // Deserialize from JSON
* {ClassName} dto = ({ClassName}) JSON.deserialize(jsonString, {ClassName}.class);
*/
public with sharing class {ClassName} {
// ─── Properties ──────────────────────────────────────────────────────
/** {Describe this property} */
public String name { get; set; }
/** {Describe this property} */
public Id recordId { get; set; }
/** {Describe this property} */
public Boolean isActive { get; set; }
/** {Describe this property} */
public List<String> tags { get; set; }
// TODO: Add additional properties as needed
// ─── Constructors ────────────────────────────────────────────────────
/**
* No-arg constructor for deserialization compatibility
*/
public {ClassName}() {
this.tags = new List<String>();
this.isActive = false;
}
/**
* Parameterized constructor for convenience
* @param name The name value
* @param recordId The associated record Id
*/
public {ClassName}(String name, Id recordId) {
this();
this.name = name;
this.recordId = recordId;
}
// ─── Factory Methods ─────────────────────────────────────────────────
/**
* Creates a DTO instance from an SObject record
* @param record The source {SObject} record
* @return A populated {ClassName} instance
*/
public static {ClassName} fromSObject(SObject record) {
if (record == null) {
return new {ClassName}();
}
{ClassName} dto = new {ClassName}();
dto.recordId = record.Id;
dto.name = (String) record.get('Name');
// TODO: Map additional fields
return dto;
}
/**
* Creates a list of DTOs from a list of SObject records
* @param records The source records
* @return List of populated {ClassName} instances
*/
public static List<{ClassName}> fromSObjects(List<SObject> records) {
List<{ClassName}> dtos = new List<{ClassName}>();
if (records == null) {
return dtos;
}
for (SObject record : records) {
dtos.add(fromSObject(record));
}
return dtos;
}
// ─── Utility Methods ─────────────────────────────────────────────────
/**
* Serializes this DTO to a JSON string
* @return JSON representation of this DTO
*/
public String toJson() {
return JSON.serialize(this);
}
/**
* Deserializes a JSON string into a {ClassName} instance
* @param jsonString The JSON string to deserialize
* @return A {ClassName} instance
*/
public static {ClassName} fromJson(String jsonString) {
return ({ClassName}) JSON.deserialize(jsonString, {ClassName}.class);
}
}
/**
* Custom exception for {describe when this exception is thrown}.
* Use this exception to signal domain-specific errors that callers
* can catch and handle distinctly from system exceptions.
*
* @example
* throw new {ClassName}('Account merge failed: duplicate detected.');
*
* // Wrap a caught exception
* try {
* // ... risky operation
* } catch (DmlException e) {
* throw new {ClassName}('DML failed during account merge: ' + e.getMessage());
* }
*/
public with sharing class {ClassName} extends Exception {
// Apex custom exceptions automatically inherit:
// - getMessage()
// - getCause()
// - getStackTraceString()
// - setMessage(String)
// - initCause(Exception)
//
// And support these constructor patterns:
// - new {ClassName}()
// - new {ClassName}('message')
// - new {ClassName}(causeException)
// - new {ClassName}('message', causeException)
//
// Note: Apex does NOT support custom constructors on Exception subclasses.
// To add context, use the message string or create a wrapper pattern:
//
// Example wrapper pattern (if you need structured error data):
//
// public class {ClassName}Detail {
// public String errorCode;
// public List<Id> failedRecordIds;
// public String detail;
//
// public {ClassName}Detail(String errorCode, List<Id> failedRecordIds, String detail) {
// this.errorCode = errorCode;
// this.failedRecordIds = failedRecordIds;
// this.detail = detail;
// }
//
// public override String toString() {
// return '[' + errorCode + '] ' + detail + ' (Records: ' + failedRecordIds + ')';
// }
// }
}
/**
* Interface for {describe the capability or contract this interface defines}.
* Implement this interface to provide {describe what implementations do}.
*
* @example
* public class EmailNotificationService implements {InterfaceName} {
* public void execute(Map<String, Object> params) {
* // Implementation
* }
* }
*/
public interface {InterfaceName} {
/**
* {Describe what this method should do}
* @param params {Describe the parameter}
* @return {Describe the return value}
*/
// TODO: Define interface methods
// Example:
// void execute(Map<String, Object> params);
// Boolean isEligible(SObject record);
// List<SObject> process(List<SObject> records);
}
/**
* Invocable Apex action for {describe the action}.
* Callable from Flows, Process Builder, and Agentforce.
* Accepts bulkified List<Request>, returns List<Response>.
*/
public with sharing class {ClassName} {
// ─── Invocable Method ────────────────────────────────────────────────
/**
* {Describe what this action does}
* @param requests List of Request inputs from the calling Flow
* @return List of Response outputs returned to the calling Flow
*/
@InvocableMethod(
label='{Display Name}'
description='{Describe the action for Flow Builder}'
category='{Category}'
)
public static List<Response> execute(List<Request> requests) {
List<Response> responses = new List<Response>();
// Collect all Ids upfront for bulkified query
Set<Id> allRecordIds = new Set<Id>();
for (Request req : requests) {
if (req.recordId != null) {
allRecordIds.add(req.recordId);
}
}
// Single bulkified query outside the loop
Map<Id, {SObject}> recordMap = allRecordIds.isEmpty()
? new Map<Id, {SObject}>()
: new Map<Id, {SObject}>([
SELECT Id, Name
// TODO: Add required fields
FROM {SObject}
WHERE Id IN :allRecordIds
WITH USER_MODE
]);
// Process each request
for (Request req : requests) {
responses.add(processRequest(req, recordMap));
}
return responses;
}
// ─── Private Helpers ─────────────────────────────────────────────────
/**
* Processes a single request and returns a response.
* Errors are captured in the response, not thrown.
* @param req The input request
* @param recordMap Pre-queried records keyed by Id
* @return A Response with success/error information
*/
private static Response processRequest(Request req, Map<Id, {SObject}> recordMap) {
Response res = new Response();
try {
{SObject} record = recordMap.get(req.recordId);
if (record == null) {
res.isSuccess = false;
res.errorMessage = 'Record not found: ' + req.recordId;
res.errorType = 'NOT_FOUND';
return res;
}
// TODO: Implement business logic
res.isSuccess = true;
} catch (Exception e) {
res.isSuccess = false;
res.errorMessage = e.getMessage();
res.errorType = e.getTypeName();
}
return res;
}
// ─── Request / Response DTOs ─────────────────────────────────────────
/**
* Input parameters from the calling Flow
*/
public class Request {
@InvocableVariable(label='Record Id' description='The Id of the record to process' required=true)
public Id recordId;
// TODO: Add additional input variables
// @InvocableVariable(label='Field Value' description='Value to set' required=false)
// public String fieldValue;
}
/**
* Output results returned to the calling Flow
*/
public class Response {
@InvocableVariable(label='Success' description='Whether the action succeeded')
public Boolean isSuccess;
@InvocableVariable(label='Error Message' description='Error details if the action failed')
public String errorMessage;
@InvocableVariable(label='Error Type' description='Exception type name if the action failed')
public String errorType;
// TODO: Add additional output variables
// @InvocableVariable(label='Result Id' description='Id of the created/updated record')
// public Id resultId;
}
}
/**
* Queueable Apex class for {describe the async operation}.
* Accepts data through the constructor for stateful processing.
* Optionally implements Database.AllowsCallouts for external integrations.
*
* @example
* // Enqueue the job
* Id jobId = System.enqueueJob(new {ClassName}(recordIds));
*/
public with sharing class {ClassName} implements Queueable /*, Database.AllowsCallouts */ {
// ─── Constants ───────────────────────────────────────────────────────
private static final Integer MAX_CHAIN_DEPTH = 5;
// ─── Instance Variables (Stateful) ───────────────────────────────────
private Set<Id> recordIds;
private Integer chainDepth;
// ─── Constructors ────────────────────────────────────────────────────
/**
* Creates a new queueable job to process the specified records
* @param recordIds Set of record Ids to process
*/
public {ClassName}(Set<Id> recordIds) {
this(recordIds, 0);
}
/**
* Creates a new queueable job with chain depth tracking
* @param recordIds Set of record Ids to process
* @param chainDepth Current depth in the queueable chain
*/
public {ClassName}(Set<Id> recordIds, Integer chainDepth) {
this.recordIds = recordIds ?? new Set<Id>();
this.chainDepth = chainDepth;
}
// ─── Queueable Interface ─────────────────────────────────────────────
/**
* Executes the asynchronous work
* @param context The queueable context
*/
public void execute(QueueableContext context) {
if (this.recordIds.isEmpty()) {
return;
}
try {
// TODO: Implement the async processing logic
// List<{SObject}> records = {SObject}Selector.selectByIds(this.recordIds);
// ... process records ...
// Chain to next job if there's more work and we haven't hit the depth limit
chainIfNeeded();
} catch (Exception e) {
handleError(context.getJobId(), e);
}
}
// ─── Private Helpers ─────────────────────────────────────────────────
/**
* Chains to the next queueable job if needed, with depth guard
*/
private void chainIfNeeded() {
// TODO: Determine if chaining is needed (e.g., remaining records to process)
Set<Id> remainingIds = new Set<Id>();
if (!remainingIds.isEmpty() && this.chainDepth < MAX_CHAIN_DEPTH) {
if (!Test.isRunningTest()) {
System.enqueueJob(new {ClassName}(remainingIds, this.chainDepth + 1));
}
}
}
/**
* Handles errors during execution
* @param jobId The async job Id
* @param e The exception that occurred
*/
private void handleError(Id jobId, Exception e) {
System.debug(LoggingLevel.ERROR,
'{ClassName} failed (Job: ' + jobId + '): ' +
e.getMessage() + '\n' + e.getStackTraceString()
);
// TODO: Persist error to a log object or send notification
}
}
/**
* REST API endpoint for {SObject} operations.
* Exposes CRUD operations via @RestResource at /services/apexrest/{urlPath}/v1/*.
* Uses versioned URL mapping for future-proof API evolution.
* All queries use WITH USER_MODE for CRUD/FLS enforcement.
*
* Authentication: OAuth 2.0 via Connected App
* Base URL: /services/apexrest/{urlPath}/v1/
*/
@RestResource(urlMapping='/{urlPath}/v1/*')
global with sharing class {ClassName} {
// ─── Constants ────────────────────────────────────────────────────────
private static final Integer DEFAULT_PAGE_SIZE = 20;
private static final Integer MAX_PAGE_SIZE = 200;
private static final String ERROR_MISSING_ID = 'Record Id is required in the URL path.';
private static final String ERROR_INVALID_ID = 'Invalid Salesforce Id format.';
private static final String ERROR_MISSING_BODY = 'Request body is required.';
private static final String ERROR_NOT_FOUND = '{SObject} record not found.';
private static final String ERROR_INSUFFICIENT_ACCESS = 'Insufficient access to perform this operation.';
// ─── GET — Retrieve ───────────────────────────────────────────────────
/**
* Retrieves a single {SObject} by Id (URL path) or a paginated list (query params).
* Single: GET /services/apexrest/{urlPath}/v1/{recordId}
* List: GET /services/apexrest/{urlPath}/v1?pageSize=20&offset=0
* @return ApiResponse containing the requested data
*/
@HttpGet
global static ApiResponse doGet() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
String recordId = extractIdFromUri(req.requestURI);
if (String.isNotBlank(recordId)) {
return getSingleRecord(recordId, res);
}
return getRecordList(req, res);
} catch (Exception e) {
return handleError(res, 500, e.getMessage());
}
}
// ─── POST — Create ───────────────────────────────────────────────────
/**
* Creates a new {SObject} record from the JSON request body.
* POST /services/apexrest/{urlPath}/v1/
* @return ApiResponse with the created record Id
*/
@HttpPost
global static ApiResponse doPost() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
if (req.requestBody == null || String.isBlank(req.requestBody.toString())) {
return handleError(res, 400, ERROR_MISSING_BODY);
}
{ClassName}Request payload = ({ClassName}Request) JSON.deserialize(
req.requestBody.toString(),
{ClassName}Request.class
);
// TODO: Map request payload to SObject fields
{SObject} record = new {SObject}(
Name = payload.name
);
Database.SaveResult saveResult = Database.insert(record, true);
if (saveResult.isSuccess()) {
res.statusCode = 201;
return new ApiResponse(true, 'Record created successfully.', record.Id);
}
return handleError(res, 422, saveResult.getErrors()[0].getMessage());
} catch (JSONException e) {
return handleError(res, 400, 'Malformed JSON: ' + e.getMessage());
} catch (DmlException e) {
return handleError(res, 422, 'Validation failed: ' + e.getDmlMessage(0));
} catch (Exception e) {
return handleError(res, 500, e.getMessage());
}
}
// ─── PATCH — Partial Update ───────────────────────────────────────────
/**
* Partially updates an existing {SObject} record.
* PATCH /services/apexrest/{urlPath}/v1/{recordId}
* @return ApiResponse confirming the update
*/
@HttpPatch
global static ApiResponse doPatch() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
String recordId = extractIdFromUri(req.requestURI);
if (String.isBlank(recordId)) {
return handleError(res, 400, ERROR_MISSING_ID);
}
if (!isValidSalesforceId(recordId)) {
return handleError(res, 400, ERROR_INVALID_ID);
}
if (req.requestBody == null || String.isBlank(req.requestBody.toString())) {
return handleError(res, 400, ERROR_MISSING_BODY);
}
List<{SObject}> existing = [
SELECT Id FROM {SObject} WHERE Id = :recordId WITH USER_MODE LIMIT 1
];
if (existing.isEmpty()) {
return handleError(res, 404, ERROR_NOT_FOUND);
}
Map<String, Object> fieldUpdates = (Map<String, Object>) JSON.deserializeUntyped(
req.requestBody.toString()
);
{SObject} record = existing[0];
// TODO: Apply allowed field updates from the payload to the record
// for (String fieldName : fieldUpdates.keySet()) {
// record.put(fieldName, fieldUpdates.get(fieldName));
// }
Database.SaveResult saveResult = Database.update(record, true);
if (saveResult.isSuccess()) {
res.statusCode = 200;
return new ApiResponse(true, 'Record updated successfully.', record.Id);
}
return handleError(res, 422, saveResult.getErrors()[0].getMessage());
} catch (JSONException e) {
return handleError(res, 400, 'Malformed JSON: ' + e.getMessage());
} catch (DmlException e) {
return handleError(res, 422, 'Validation failed: ' + e.getDmlMessage(0));
} catch (Exception e) {
return handleError(res, 500, e.getMessage());
}
}
// ─── DELETE — Remove ──────────────────────────────────────────────────
/**
* Deletes a {SObject} record by Id.
* DELETE /services/apexrest/{urlPath}/v1/{recordId}
* @return ApiResponse confirming the deletion
*/
@HttpDelete
global static ApiResponse doDelete() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
String recordId = extractIdFromUri(req.requestURI);
if (String.isBlank(recordId)) {
return handleError(res, 400, ERROR_MISSING_ID);
}
if (!isValidSalesforceId(recordId)) {
return handleError(res, 400, ERROR_INVALID_ID);
}
List<{SObject}> existing = [
SELECT Id FROM {SObject} WHERE Id = :recordId WITH USER_MODE LIMIT 1
];
if (existing.isEmpty()) {
return handleError(res, 404, ERROR_NOT_FOUND);
}
Database.DeleteResult deleteResult = Database.delete(existing[0], true);
if (deleteResult.isSuccess()) {
res.statusCode = 200;
return new ApiResponse(true, 'Record deleted successfully.', recordId);
}
return handleError(res, 422, deleteResult.getErrors()[0].getMessage());
} catch (DmlException e) {
return handleError(res, 422, e.getDmlMessage(0));
} catch (Exception e) {
return handleError(res, 500, e.getMessage());
}
}
// ─── Private Helpers ──────────────────────────────────────────────────
private static ApiResponse getSingleRecord(String recordId, RestResponse res) {
if (!isValidSalesforceId(recordId)) {
return handleError(res, 400, ERROR_INVALID_ID);
}
List<{SObject}> records = [
SELECT Id, Name, CreatedDate, LastModifiedDate
FROM {SObject}
WHERE Id = :recordId
WITH USER_MODE
LIMIT 1
];
if (records.isEmpty()) {
return handleError(res, 404, ERROR_NOT_FOUND);
}
res.statusCode = 200;
ApiResponse response = new ApiResponse(true, 'Record retrieved successfully.', recordId);
response.data = records[0];
return response;
}
private static ApiResponse getRecordList(RestRequest req, RestResponse res) {
Integer pageSize = getIntParam(req, 'pageSize', DEFAULT_PAGE_SIZE);
Integer offset = getIntParam(req, 'offset', 0);
pageSize = Math.min(pageSize, MAX_PAGE_SIZE);
List<{SObject}> records = [
SELECT Id, Name, CreatedDate, LastModifiedDate
FROM {SObject}
WITH USER_MODE
ORDER BY Name ASC
LIMIT :pageSize
OFFSET :offset
];
res.statusCode = 200;
ApiResponse response = new ApiResponse(true, 'Records retrieved successfully.', null);
response.records = records;
response.pageSize = pageSize;
response.offset = offset;
return response;
}
private static String extractIdFromUri(String uri) {
String lastSegment = uri.substringAfterLast('/');
if (String.isBlank(lastSegment) || lastSegment == 'v1') {
return null;
}
return lastSegment;
}
private static Boolean isValidSalesforceId(String idValue) {
return Pattern.matches('[a-zA-Z0-9]{15,18}', idValue);
}
private static Integer getIntParam(RestRequest req, String paramName, Integer defaultValue) {
String paramValue = req.params.get(paramName);
if (String.isBlank(paramValue)) {
return defaultValue;
}
try {
return Integer.valueOf(paramValue);
} catch (TypeException e) {
return defaultValue;
}
}
private static ApiResponse handleError(RestResponse res, Integer statusCode, String message) {
res.statusCode = statusCode;
return new ApiResponse(false, message, null);
}
// ─── Request / Response DTOs ──────────────────────────────────────────
/**
* Inbound request payload for POST operations.
* Extend with additional fields as needed.
*/
global class {ClassName}Request {
global String name;
// TODO: Add fields matching the expected JSON request body
}
/**
* Standardized API response envelope.
* All endpoints return this structure for consistent client parsing.
*/
global class ApiResponse {
global Boolean success;
global String message;
global String recordId;
global SObject data;
global List<SObject> records;
global Integer pageSize;
global Integer offset;
global ApiResponse(Boolean success, String message, String recordId) {
this.success = success;
this.message = message;
this.recordId = recordId;
}
}
}/**
* Schedulable Apex class for {describe the scheduled operation}.
* Delegates heavy processing to a Batch or Queueable job.
* Keep execute() lightweight — it should only launch other jobs.
*
* @example
* // Schedule to run daily at 2 AM
* String jobId = System.schedule(
* '{ClassName} - Daily',
* {ClassName}.CRON_DAILY_2AM,
* new {ClassName}()
* );
*
* // Or use the convenience method
* String jobId = {ClassName}.scheduleDaily();
*/
public with sharing class {ClassName} implements Schedulable {
// ─── CRON Expressions ────────────────────────────────────────────────
// Seconds Minutes Hours Day_of_month Month Day_of_week Optional_year
/** Runs daily at 2:00 AM */
public static final String CRON_DAILY_2AM = '0 0 2 * * ?';
/** Runs every weekday at 6:00 AM */
public static final String CRON_WEEKDAYS_6AM = '0 0 6 ? * MON-FRI';
/** Runs hourly at the top of the hour */
public static final String CRON_HOURLY = '0 0 * * * ?';
// ─── Schedulable Interface ───────────────────────────────────────────
/**
* Entry point for the scheduled execution.
* Delegates to a Batch or Queueable for the actual work.
* @param sc The schedulable context
*/
public void execute(SchedulableContext sc) {
// Option A: Launch a Batch job
// Database.executeBatch(new {BatchClassName}(), 200);
// Option B: Launch a Queueable job
// System.enqueueJob(new {QueueableClassName}(params));
// TODO: Implement delegation to appropriate async job
}
// ─── Convenience Scheduling Methods ──────────────────────────────────
/**
* Schedules this job to run daily at 2 AM
* @return The scheduled job Id
*/
public static String scheduleDaily() {
return System.schedule(
'{ClassName} - Daily 2AM',
CRON_DAILY_2AM,
new {ClassName}()
);
}
/**
* Aborts this scheduled job by name
* @param jobName The name used when scheduling
*/
public static void abort(String jobName) {
for (CronTrigger ct : [
SELECT Id FROM CronTrigger
WHERE CronJobDetail.Name = :jobName
]) {
System.abortJob(ct.Id);
}
}
}
/**
* Selector class for {SObject} queries.
* Encapsulates all SOQL for {SObject} records.
* All methods return bulkified results (Lists or Maps).
*/
public inherited sharing class {SObject}Selector {
// ─── Field Lists ─────────────────────────────────────────────────────
/**
* Returns the default set of fields to query for {SObject}.
* Centralizes field references to keep queries DRY.
* @return Comma-separated field list as a String
*/
private static String getDefaultFields() {
return String.join(
new List<String>{
'Id',
'Name',
'CreatedDate',
'LastModifiedDate'
// TODO: Add additional fields here
},
', '
);
}
// ─── Query Methods ───────────────────────────────────────────────────
/**
* Selects {SObject} records by their Ids
* @param recordIds Set of {SObject} Ids to query
* @return List of {SObject} records matching the provided Ids
* @example
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
* List<{SObject}> results = {SObject}Selector.selectByIds(ids);
*/
public static List<{SObject}> selectByIds(Set<Id> recordIds) {
if (recordIds == null || recordIds.isEmpty()) {
return new List<{SObject}>();
}
return Database.query(
'SELECT ' + getDefaultFields() +
' FROM {SObject}' +
' WHERE Id IN :recordIds'
);
}
/**
* Selects {SObject} records as a Map keyed by Id
* @param recordIds Set of {SObject} Ids to query
* @return Map of Id to {SObject}
*/
public static Map<Id, {SObject}> selectMapByIds(Set<Id> recordIds) {
return new Map<Id, {SObject}>(selectByIds(recordIds));
}
/**
* Selects {SObject} records by a specific field value
* @param fieldName API name of the field to filter on
* @param values Set of values to match
* @return List of matching {SObject} records
* @example
* List<{SObject}> results = {SObject}Selector.selectByField('Status__c', new Set<String>{ 'Active' });
*/
public static List<{SObject}> selectByField(String fieldName, Set<String> values) {
if (String.isBlank(fieldName) || values == null || values.isEmpty()) {
return new List<{SObject}>();
}
// Validate field name to prevent SOQL injection
Schema.SObjectField field = Schema.SObjectType.{SObject}.fields.getMap().get(fieldName);
if (field == null) {
throw new QueryException('Invalid field name: ' + fieldName);
}
return Database.query(
'SELECT ' + getDefaultFields() +
' FROM {SObject}' +
' WHERE ' + String.escapeSingleQuotes(fieldName) + ' IN :values'
);
}
// ─── Exception ───────────────────────────────────────────────────────
/**
* Custom exception for query errors
*/
public class QueryException extends Exception {}
}
/**
* Service class for {SObject} business logic.
* Follows separation of concerns: delegates queries to {SObject}Selector
* and SObject manipulation to {SObject}Domain where applicable.
*/
public with sharing class {SObject}Service {
// ─── Constants ───────────────────────────────────────────────────────
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
// ─── Public API ──────────────────────────────────────────────────────
/**
* {Describe the primary operation}
* @param recordIds Set of {SObject} Ids to process
* @return List of processed {SObject} records
* @throws {SObject}ServiceException if processing fails
* @example
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
* List<{SObject}> results = {SObject}Service.process{SObject}s(ids);
*/
public static List<{SObject}> process{SObject}s(Set<Id> recordIds) {
// Guard clause
if (recordIds == null || recordIds.isEmpty()) {
throw new {SObject}ServiceException(ERROR_NULL_INPUT);
}
// Query via Selector
List<{SObject}> records = {SObject}Selector.selectByIds(recordIds);
// Apply business logic
// TODO: Implement business logic here
// DML
try {
update records;
} catch (DmlException e) {
throw new {SObject}ServiceException(
'Failed to update {SObject} records: ' + e.getMessage()
);
}
return records;
}
// ─── Convenience Overloads ───────────────────────────────────────────
/**
* Single-record convenience overload
* @param recordId The {SObject} Id to process
* @return The processed {SObject} record
*/
public static {SObject} process{SObject}(Id recordId) {
List<{SObject}> results = process{SObject}s(new Set<Id>{ recordId });
return results.isEmpty() ? null : results[0];
}
// ─── Private Helpers ─────────────────────────────────────────────────
// TODO: Add private helper methods as needed
// ─── Exception ───────────────────────────────────────────────────────
/**
* Custom exception for {SObject}Service errors
*/
public class {SObject}ServiceException extends Exception {}
}
/**
* {SObject} Trigger
*/
trigger {SObject}Trigger on {SObject} (
before insert,
before update,
before delete,
after insert,
after update,
after delete,
after undelete
) {
// ─── Option A: Trigger Actions Framework (TAF) ───────────────────────
// Delegates to Trigger_Action__mdt-registered action classes.
// Each action class handles one concern in one context.
//
// new MetadataTriggerHandler().run();
// ─── Option B: Custom Handler Pattern ────────────────────────────────
// Delegates to a single handler class that routes by context.
//
// {SObject}TriggerHandler handler = new {SObject}TriggerHandler();
//
// if (Trigger.isBefore) {
// if (Trigger.isInsert) {
// handler.beforeInsert(Trigger.new);
// } else if (Trigger.isUpdate) {
// handler.beforeUpdate(Trigger.new, Trigger.oldMap);
// } else if (Trigger.isDelete) {
// handler.beforeDelete(Trigger.old, Trigger.oldMap);
// }
// } else if (Trigger.isAfter) {
// if (Trigger.isInsert) {
// handler.afterInsert(Trigger.new, Trigger.newMap);
// } else if (Trigger.isUpdate) {
// handler.afterUpdate(Trigger.new, Trigger.oldMap);
// } else if (Trigger.isDelete) {
// handler.afterDelete(Trigger.old, Trigger.oldMap);
// } else if (Trigger.isUndelete) {
// handler.afterUndelete(Trigger.new, Trigger.newMap);
// }
// }
// TODO: Uncomment one option above and remove the other
}
/**
* Utility class for {describe the category of utilities: String, Date, Collection, etc.}.
* All methods are static and side-effect-free (no SOQL, no DML).
* Private constructor prevents instantiation.
*/
public with sharing class {ClassName} {
// ─── Private Constructor ─────────────────────────────────────────────
/**
* Prevents instantiation — use static methods only
*/
@TestVisible
private {ClassName}() {
// Utility class — do not instantiate
}
// ─── Public Methods ──────────────────────────────────────────────────
// TODO: Add utility methods below. Examples:
/**
* Safely converts a String to an Integer, returning a default if parsing fails
* @param value The String to parse
* @param defaultValue The fallback value if parsing fails
* @return The parsed Integer or the default value
* @example
* Integer result = {ClassName}.safeParseInteger('42', 0); // returns 42
* Integer result = {ClassName}.safeParseInteger('abc', 0); // returns 0
*/
public static Integer safeParseInteger(String value, Integer defaultValue) {
if (String.isBlank(value)) {
return defaultValue;
}
try {
return Integer.valueOf(value.trim());
} catch (TypeException e) {
return defaultValue;
}
}
/**
* Chunks a list into smaller sublists of the specified size.
* Useful for processing records in governor-limit-safe batches.
* @param items The list to chunk
* @param chunkSize The maximum size of each chunk
* @return A list of sublists, each containing up to chunkSize elements
* @example
* List<List<String>> chunks = {ClassName}.chunkList(myList, 200);
*/
public static List<List<Object>> chunkList(List<Object> items, Integer chunkSize) {
List<List<Object>> chunks = new List<List<Object>>();
if (items == null || items.isEmpty() || chunkSize <= 0) {
return chunks;
}
List<Object> currentChunk = new List<Object>();
for (Object item : items) {
currentChunk.add(item);
if (currentChunk.size() == chunkSize) {
chunks.add(currentChunk);
currentChunk = new List<Object>();
}
}
if (!currentChunk.isEmpty()) {
chunks.add(currentChunk);
}
return chunks;
}
/**
* Extracts a Set of non-null field values from a list of SObjects
* @param records The SObject records to extract from
* @param fieldName The API name of the field to extract
* @return A Set of non-null String values
* @example
* Set<String> emails = {ClassName}.pluckStrings(contacts, 'Email');
*/
public static Set<String> pluckStrings(List<SObject> records, String fieldName) {
Set<String> values = new Set<String>();
if (records == null || String.isBlank(fieldName)) {
return values;
}
for (SObject record : records) {
Object val = record.get(fieldName);
if (val != null) {
values.add(String.valueOf(val));
}
}
return values;
}
}
/**
* Batch Apex class for identifying and flagging duplicate Account records.
* Compares Accounts by Name and BillingPostalCode to find potential duplicates.
* Flags duplicates by setting the Is_Potential_Duplicate__c checkbox.
* Implements Database.Stateful to track results across batch chunks.
*
* @example
* // Run with default batch size (200)
* Id jobId = AccountDeduplicationBatch.run();
*
* // Run with smaller batch size for complex processing
* Database.executeBatch(new AccountDeduplicationBatch(), 50);
*/
public with sharing class AccountDeduplicationBatch implements Database.Batchable<SObject>, Database.Stateful {
// ─── Constants ───────────────────────────────────────────────────────
private static final Integer DEFAULT_BATCH_SIZE = 200;
// ─── Stateful Tracking ───────────────────────────────────────────────
private Integer totalScanned = 0;
private Integer duplicatesFound = 0;
private Integer totalErrors = 0;
private List<String> errorMessages = new List<String>();
// ─── Batchable Interface ─────────────────────────────────────────────
/**
* Queries all active Accounts that haven't already been flagged
* @param bc The batch context
* @return QueryLocator scoped to unflagged active Accounts
*/
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, BillingPostalCode, Is_Potential_Duplicate__c
FROM Account
WHERE IsDeleted = FALSE
AND Is_Potential_Duplicate__c = FALSE
ORDER BY Name ASC
]);
}
/**
* Processes each batch by building a duplicate key and checking for matches.
* Uses a composite key of normalized Name + BillingPostalCode.
* @param bc The batch context
* @param scope List of Account records in the current batch
*/
public void execute(Database.BatchableContext bc, List<Account> scope) {
totalScanned += scope.size();
// Build duplicate keys for this batch
Map<String, List<Account>> dupeKeyMap = new Map<String, List<Account>>();
for (Account acct : scope) {
String key = buildDuplicateKey(acct);
if (String.isNotBlank(key)) {
if (!dupeKeyMap.containsKey(key)) {
dupeKeyMap.put(key, new List<Account>());
}
dupeKeyMap.get(key).add(acct);
}
}
// Flag records that share a key with another record
List<Account> toUpdate = new List<Account>();
for (String key : dupeKeyMap.keySet()) {
List<Account> group = dupeKeyMap.get(key);
if (group.size() > 1) {
for (Account acct : group) {
toUpdate.add(new Account(
Id = acct.Id,
Is_Potential_Duplicate__c = true
));
}
}
}
if (!toUpdate.isEmpty()) {
List<Database.SaveResult> results = Database.update(toUpdate, false);
processResults(results);
}
}
/**
* Logs a summary of the deduplication batch run
* @param bc The batch context
*/
public void finish(Database.BatchableContext bc) {
String summary = String.format(
'AccountDeduplicationBatch completed. Scanned: {0}, Duplicates flagged: {1}, Errors: {2}',
new List<Object>{ totalScanned, duplicatesFound, totalErrors }
);
System.debug(LoggingLevel.INFO, summary);
if (!errorMessages.isEmpty()) {
System.debug(LoggingLevel.ERROR, 'Error details:\n' + String.join(errorMessages, '\n'));
}
}
// ─── Private Helpers ─────────────────────────────────────────────────
/**
* Builds a normalized composite key for duplicate detection
* @param acct The Account record
* @return Normalized key string, or null if insufficient data
*/
private String buildDuplicateKey(Account acct) {
if (String.isBlank(acct.Name)) {
return null;
}
String normalizedName = acct.Name.trim().toUpperCase().replaceAll('\\s+', ' ');
String postalCode = (acct.BillingPostalCode ?? '').trim().toUpperCase();
return normalizedName + '|' + postalCode;
}
/**
* Processes DML results, tracking successes and failures
* @param results List of Database.SaveResult from update operation
*/
private void processResults(List<Database.SaveResult> results) {
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
duplicatesFound++;
} else {
totalErrors++;
for (Database.Error err : sr.getErrors()) {
errorMessages.add(
'Record ' + sr.getId() + ': ' +
err.getStatusCode() + ' - ' + err.getMessage()
);
}
}
}
}
// ─── Static Helpers ──────────────────────────────────────────────────
/**
* Convenience method to execute with default batch size
* @return The batch job Id
*/
public static Id run() {
return Database.executeBatch(new AccountDeduplicationBatch(), DEFAULT_BATCH_SIZE);
}
}
/**
* Selector class for Account queries.
* Encapsulates all SOQL for Account records.
* All methods return bulkified results (Lists or Maps).
*/
public with sharing class AccountSelector {
// ─── Field Lists ─────────────────────────────────────────────────────
/**
* Returns the default set of fields to query for Account.
* Centralizes field references to keep queries DRY.
* @return Comma-separated field list as a String
*/
private static String getDefaultFields() {
return String.join(
new List<String>{
'Id',
'Name',
'AccountNumber',
'Type',
'Industry',
'AnnualRevenue',
'NumberOfEmployees',
'Phone',
'Website',
'OwnerId',
'CreatedDate',
'LastModifiedDate'
},
', '
);
}
/**
* Returns fields needed for billing/territory operations
* @return Comma-separated field list as a String
*/
private static String getBillingFields() {
return String.join(
new List<String>{
'Id',
'Name',
'BillingStreet',
'BillingCity',
'BillingState',
'BillingPostalCode',
'BillingCountry',
'Territory__c'
},
', '
);
}
// ─── Query Methods ───────────────────────────────────────────────────
/**
* Selects Account records by their Ids
* @param recordIds Set of Account Ids to query
* @return List of Account records matching the provided Ids
* @example
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
* List<Account> results = AccountSelector.selectByIds(ids);
*/
public static List<Account> selectByIds(Set<Id> recordIds) {
if (recordIds == null || recordIds.isEmpty()) {
return new List<Account>();
}
return Database.query(
'SELECT ' + getDefaultFields() +
' FROM Account' +
' WHERE Id IN :recordIds'
);
}
/**
* Selects Account records as a Map keyed by Id
* @param recordIds Set of Account Ids to query
* @return Map of Id to Account
*/
public static Map<Id, Account> selectMapByIds(Set<Id> recordIds) {
return new Map<Id, Account>(selectByIds(recordIds));
}
/**
* Selects Accounts with billing address fields for territory assignment
* @param recordIds Set of Account Ids to query
* @return List of Account records with billing address fields populated
*/
public static List<Account> selectWithBillingAddress(Set<Id> recordIds) {
if (recordIds == null || recordIds.isEmpty()) {
return new List<Account>();
}
return Database.query(
'SELECT ' + getBillingFields() +
' FROM Account' +
' WHERE Id IN :recordIds'
);
}
/**
* Selects Accounts by Account Type
* @param accountTypes Set of Account Type values to filter by
* @return List of matching Account records
* @example
* List<Account> prospects = AccountSelector.selectByType(
* new Set<String>{ 'Prospect', 'Customer - Direct' }
* );
*/
public static List<Account> selectByType(Set<String> accountTypes) {
if (accountTypes == null || accountTypes.isEmpty()) {
return new List<Account>();
}
return Database.query(
'SELECT ' + getDefaultFields() +
' FROM Account' +
' WHERE Type IN :accountTypes' +
' ORDER BY Name ASC'
);
}
/**
* Selects Accounts by Industry with a minimum annual revenue
* @param industries Set of Industry values to filter by
* @param minRevenue Minimum AnnualRevenue threshold
* @return List of matching Account records ordered by revenue descending
* @example
* List<Account> techAccounts = AccountSelector.selectByIndustryAndRevenue(
* new Set<String>{ 'Technology' },
* 1000000
* );
*/
public static List<Account> selectByIndustryAndRevenue(Set<String> industries, Decimal minRevenue) {
if (industries == null || industries.isEmpty()) {
return new List<Account>();
}
Decimal revenueThreshold = minRevenue ?? 0;
return Database.query(
'SELECT ' + getDefaultFields() +
' FROM Account' +
' WHERE Industry IN :industries' +
' AND AnnualRevenue >= :revenueThreshold' +
' ORDER BY AnnualRevenue DESC'
);
}
/**
* Selects Accounts with their related Contacts (subquery)
* @param recordIds Set of Account Ids to query
* @return List of Account records with nested Contacts
*/
public static List<Account> selectWithContacts(Set<Id> recordIds) {
if (recordIds == null || recordIds.isEmpty()) {
return new List<Account>();
}
return [
SELECT Id, Name, Type, Industry,
(SELECT Id, FirstName, LastName, Email, Title
FROM Contacts
ORDER BY LastName ASC)
FROM Account
WHERE Id IN :recordIds
];
}
// ─── Aggregate Queries ───────────────────────────────────────────────
/**
* Returns a count of Accounts grouped by Industry
* @return List of AggregateResult with Industry and record count
* @example
* List<AggregateResult> results = AccountSelector.countByIndustry();
* for (AggregateResult ar : results) {
* System.debug(ar.get('Industry') + ': ' + ar.get('cnt'));
* }
*/
public static List<AggregateResult> countByIndustry() {
return [
SELECT Industry, COUNT(Id) cnt
FROM Account
WHERE Industry != NULL
GROUP BY Industry
ORDER BY COUNT(Id) DESC
];
}
}
/**
* Service class for Account business logic.
* Provides account deduplication, enrichment, and territory assignment.
* Delegates queries to AccountSelector and SObject manipulation to AccountDomain.
*/
public with sharing class AccountService {
// ─── Constants ───────────────────────────────────────────────────────
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
private static final String ERROR_MERGE_FAILED = 'Account merge failed for master Id: ';
private static final Integer MAX_MERGE_BATCH = 3;
// ─── Public API ──────────────────────────────────────────────────────
/**
* Merges duplicate Account records into a master record.
* The master record retains its field values; child records are reparented.
* @param masterIds Map of master Account Id to Set of duplicate Account Ids to merge
* @return List of master Account Ids that were successfully merged
* @throws AccountServiceException if merge processing fails
* @example
* Map<Id, Set<Id>> mergeMap = new Map<Id, Set<Id>>{
* masterAcctId => new Set<Id>{ dupeId1, dupeId2 }
* };
* List<Id> mergedIds = AccountService.mergeAccounts(mergeMap);
*/
public static List<Id> mergeAccounts(Map<Id, Set<Id>> mergeMap) {
if (mergeMap == null || mergeMap.isEmpty()) {
throw new AccountServiceException(ERROR_NULL_INPUT);
}
// Collect all Ids for a single query
Set<Id> allIds = new Set<Id>();
allIds.addAll(mergeMap.keySet());
for (Set<Id> dupeIds : mergeMap.values()) {
allIds.addAll(dupeIds);
}
// Query all records at once via Selector
Map<Id, Account> accountMap = AccountSelector.selectMapByIds(allIds);
List<Id> successfulMerges = new List<Id>();
List<String> errors = new List<String>();
for (Id masterId : mergeMap.keySet()) {
Account master = accountMap.get(masterId);
if (master == null) {
errors.add('Master account not found: ' + masterId);
continue;
}
List<Account> duplicates = new List<Account>();
for (Id dupeId : mergeMap.get(masterId)) {
Account dupe = accountMap.get(dupeId);
if (dupe != null) {
duplicates.add(dupe);
}
}
try {
// Apex merge supports up to 3 records at a time
for (List<Account> chunk : chunkAccounts(duplicates, MAX_MERGE_BATCH)) {
Database.merge(master, chunk);
}
successfulMerges.add(masterId);
} catch (DmlException e) {
errors.add(ERROR_MERGE_FAILED + masterId + ' - ' + e.getMessage());
}
}
if (!errors.isEmpty()) {
System.debug(LoggingLevel.WARN, 'Merge errors: ' + String.join(errors, '\n'));
}
return successfulMerges;
}
/**
* Assigns accounts to territories based on Billing State/Country.
* Uses Custom Metadata Type (Territory_Mapping__mdt) for mappings.
* @param accountIds Set of Account Ids to assign territories for
* @return Number of accounts successfully updated
* @throws AccountServiceException if territory assignment fails
*/
public static Integer assignTerritories(Set<Id> accountIds) {
if (accountIds == null || accountIds.isEmpty()) {
throw new AccountServiceException(ERROR_NULL_INPUT);
}
List<Account> accounts = AccountSelector.selectWithBillingAddress(accountIds);
Map<String, String> territoryMap = loadTerritoryMappings();
List<Account> toUpdate = new List<Account>();
for (Account acct : accounts) {
String key = buildTerritoryKey(acct.BillingState, acct.BillingCountry);
String territory = territoryMap.get(key);
if (territory != null && territory != acct.Territory__c) {
toUpdate.add(new Account(
Id = acct.Id,
Territory__c = territory
));
}
}
if (!toUpdate.isEmpty()) {
List<Database.SaveResult> results = Database.update(toUpdate, false);
return countSuccesses(results);
}
return 0;
}
// ─── Convenience Overloads ───────────────────────────────────────────
/**
* Single-account territory assignment convenience method
* @param accountId The Account Id to assign a territory for
* @return 1 if updated, 0 if no change needed
*/
public static Integer assignTerritory(Id accountId) {
return assignTerritories(new Set<Id>{ accountId });
}
// ─── Private Helpers ─────────────────────────────────────────────────
/**
* Loads territory mappings from Custom Metadata
* @return Map of territory key (State:Country) to territory name
*/
private static Map<String, String> loadTerritoryMappings() {
Map<String, String> mappings = new Map<String, String>();
for (Territory_Mapping__mdt mapping : Territory_Mapping__mdt.getAll().values()) {
String key = buildTerritoryKey(mapping.State__c, mapping.Country__c);
mappings.put(key, mapping.Territory_Name__c);
}
return mappings;
}
/**
* Builds a consistent territory lookup key
* @param state The billing state
* @param country The billing country
* @return A normalized key string
*/
private static String buildTerritoryKey(String state, String country) {
return (state ?? '').toUpperCase() + ':' + (country ?? '').toUpperCase();
}
/**
* Chunks a list of Accounts into sublists of the given size
* @param accounts The accounts to chunk
* @param chunkSize Maximum chunk size
* @return List of account sublists
*/
private static List<List<Account>> chunkAccounts(List<Account> accounts, Integer chunkSize) {
List<List<Account>> chunks = new List<List<Account>>();
List<Account> current = new List<Account>();
for (Account acct : accounts) {
current.add(acct);
if (current.size() == chunkSize) {
chunks.add(current);
current = new List<Account>();
}
}
if (!current.isEmpty()) {
chunks.add(current);
}
return chunks;
}
/**
* Counts successful results from a DML operation
* @param results List of Database.SaveResult
* @return Count of successful operations
*/
private static Integer countSuccesses(List<Database.SaveResult> results) {
Integer count = 0;
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
count++;
} else {
for (Database.Error err : sr.getErrors()) {
System.debug(LoggingLevel.WARN,
'Update failed for ' + sr.getId() + ': ' + err.getMessage()
);
}
}
}
return count;
}
// ─── Exception ───────────────────────────────────────────────────────
/**
* Custom exception for AccountService errors
*/
public class AccountServiceException extends Exception {}
}
Related skills
FAQ
Is Platform Apex Generate safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.