mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-09 17:22:00 +08:00
added apex and apex test
This commit is contained in:
parent
56be3dde61
commit
28de60cd53
119
skills/generating-apex-test/SKILL.md
Normal file
119
skills/generating-apex-test/SKILL.md
Normal file
@ -0,0 +1,119 @@
|
||||
---
|
||||
name: generating-apex-test
|
||||
description: >
|
||||
Apex test class generation with TestDataFactory patterns, bulk testing (200+
|
||||
records), mocking strategies for callouts and async operations, and assertion
|
||||
best practices. Use when creating new Apex test classes, improving test
|
||||
coverage, refactoring existing tests, or implementing proper testing patterns
|
||||
for triggers, services, controllers, batch jobs, queueables, and integrations.
|
||||
|
||||
Trigger on any request to create, write, or generate an Apex test class —
|
||||
even when the user doesn't say "test" explicitly. Activate on phrases like:
|
||||
"write tests for," "add coverage for," "test this class," "create a mock,"
|
||||
"I need test data," "improve coverage," or any mention of Apex testing,
|
||||
assertions, or test factories.
|
||||
---
|
||||
|
||||
# Apex Test Class Skill
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Bulkify tests** - Always test with 200+ records to catch governor limit issues
|
||||
2. **Isolate test data** - Use `@TestSetup` and TestDataFactory; never rely on org data
|
||||
3. **Assert meaningfully** - Test behavior, not just coverage; include failure messages
|
||||
4. **Mock external dependencies** - Use `HttpCalloutMock`, `Test.setMock()` for integrations
|
||||
5. **Test negative paths** - Validate error handling, not just happy paths
|
||||
|
||||
## Test Class Structure
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
private class MyServiceTest {
|
||||
|
||||
@TestSetup
|
||||
static void setupTestData() {
|
||||
// Create shared test data using TestDataFactory
|
||||
List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldPerformExpectedBehavior_WhenValidInput() {
|
||||
// Given: Setup specific test state
|
||||
List<Account> accounts = [SELECT Id, Name FROM Account];
|
||||
|
||||
// When: Execute the code under test
|
||||
Test.startTest();
|
||||
MyService.processAccounts(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Assert expected outcomes
|
||||
List<Account> updated = [SELECT Id, Status__c FROM Account];
|
||||
System.assertEquals(200, updated.size(), 'All accounts should be processed');
|
||||
for (Account acc : updated) {
|
||||
System.assertEquals('Processed', acc.Status__c, 'Status should be updated');
|
||||
}
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldThrowException_WhenInvalidInput() {
|
||||
// Given
|
||||
List<Account> emptyList = new List<Account>();
|
||||
|
||||
// When/Then
|
||||
Test.startTest();
|
||||
try {
|
||||
MyService.processAccounts(emptyList);
|
||||
System.assert(false, 'Expected MyCustomException to be thrown');
|
||||
} catch (MyCustomException e) {
|
||||
System.assert(e.getMessage().contains('cannot be empty'),
|
||||
'Exception message should indicate empty input');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
Use descriptive method names: `should[ExpectedBehavior]_When[Condition]`
|
||||
|
||||
Examples:
|
||||
- `shouldCreateContact_WhenAccountIsActive`
|
||||
- `shouldThrowException_WhenEmailIsInvalid`
|
||||
- `shouldSendNotification_WhenOpportunityClosedWon`
|
||||
- `shouldBypassTrigger_WhenRunningAsBatch`
|
||||
|
||||
## Test.startTest() / Test.stopTest()
|
||||
|
||||
Always wrap the code under test:
|
||||
- Resets governor limits for accurate limit testing
|
||||
- Executes async operations synchronously (queueables, batch, future)
|
||||
- Fires scheduled jobs immediately
|
||||
|
||||
## Asset Templates
|
||||
|
||||
Ready-to-use scaffolds for common test patterns:
|
||||
|
||||
- **[assets/test-class-template.cls](assets/test-class-template.cls)** - Starter test class with positive, negative, bulk, and governor limit test stubs
|
||||
- **[assets/test-data-factory-template.cls](assets/test-data-factory-template.cls)** - TestDataFactory with Account, Contact, Opportunity, User factories and field override support
|
||||
|
||||
## Reference Files
|
||||
|
||||
Detailed patterns for specific scenarios:
|
||||
|
||||
- **[references/test-data-factory.md](references/test-data-factory.md)** - TestDataFactory class patterns and field defaults
|
||||
- **[references/assertion-patterns.md](references/assertion-patterns.md)** - Assertion best practices and common pitfalls
|
||||
- **[references/mocking-patterns.md](references/mocking-patterns.md)** - HttpCalloutMock, Test.setMock(), stubbing
|
||||
- **[references/async-testing.md](references/async-testing.md)** - Batch, Queueable, Future, Scheduled job testing
|
||||
|
||||
## Quick Reference: What to Test
|
||||
|
||||
| Component | Key Test Scenarios |
|
||||
|-----------|-------------------|
|
||||
| Trigger | Bulk insert/update/delete, recursion, field changes |
|
||||
| Service | Valid/invalid inputs, bulk operations, exceptions |
|
||||
| Controller | Page load, action methods, view state |
|
||||
| Batch | Start/execute/finish, chunking, error records |
|
||||
| Queueable | Chaining, bulkification, error handling |
|
||||
| Callout | Success response, error response, timeout |
|
||||
| Scheduled | Execution, CRON validation |
|
||||
124
skills/generating-apex-test/assets/test-class-template.cls
Normal file
124
skills/generating-apex-test/assets/test-class-template.cls
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @description Test class for {ClassUnderTest}.
|
||||
* Tests bulk operations (200+ records), positive/negative paths,
|
||||
* and exception handling.
|
||||
* @author Generated by Apex Test Writer Skill
|
||||
*/
|
||||
@isTest
|
||||
private class {ClassUnderTest}Test {
|
||||
|
||||
// ─── Test Setup ───────────────────────────────────────────────────────
|
||||
|
||||
@TestSetup
|
||||
static void setupTestData() {
|
||||
// Create shared test data using TestDataFactory
|
||||
// List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
}
|
||||
|
||||
// ─── Positive Tests ───────────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldPerformExpectedBehavior_WhenValidInput() {
|
||||
// Given: Setup specific test state
|
||||
// List<Account> accounts = [SELECT Id, Name FROM Account];
|
||||
|
||||
// When: Execute the code under test
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.methodUnderTest(params);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Assert expected outcomes
|
||||
// System.assertEquals(expected, actual, 'Descriptive failure message');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldHandleBulkRecords_WhenProcessing200() {
|
||||
// Given: 200+ records to verify bulkification
|
||||
// List<Account> accounts = [SELECT Id FROM Account];
|
||||
// System.assertEquals(200, accounts.size(), 'Should have 200 test records');
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.bulkMethod(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Verify all records processed
|
||||
// List<Account> results = [SELECT Id, Status__c FROM Account];
|
||||
// for (Account acc : results) {
|
||||
// System.assertEquals('Processed', acc.Status__c, 'All records should be processed');
|
||||
// }
|
||||
}
|
||||
|
||||
// ─── Negative Tests ───────────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldThrowException_WhenNullInput() {
|
||||
Boolean exceptionThrown = false;
|
||||
String exceptionMessage = '';
|
||||
|
||||
Test.startTest();
|
||||
try {
|
||||
// {ClassUnderTest}.methodUnderTest(null);
|
||||
} catch (Exception e) {
|
||||
exceptionThrown = true;
|
||||
exceptionMessage = e.getMessage();
|
||||
}
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(exceptionThrown, 'Exception should be thrown for null input');
|
||||
System.assert(exceptionMessage.contains('cannot be null'),
|
||||
'Exception message should mention null input');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldReturnEmpty_WhenEmptyInput() {
|
||||
Test.startTest();
|
||||
// List<SObject> results = {ClassUnderTest}.methodUnderTest(new List<Id>());
|
||||
Test.stopTest();
|
||||
|
||||
// System.assert(results.isEmpty(), 'Should return empty list for empty input');
|
||||
}
|
||||
|
||||
// ─── Edge Case Tests ──────────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldHandleMixedRecords_WhenSomeQualify() {
|
||||
// Given: Mix of qualifying and non-qualifying records
|
||||
// List<Account> accounts = [SELECT Id, Status__c FROM Account];
|
||||
// Integer half = accounts.size() / 2;
|
||||
// for (Integer i = 0; i < half; i++) {
|
||||
// accounts[i].Status__c = 'Qualifying';
|
||||
// }
|
||||
// update accounts;
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.conditionalMethod(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Only qualifying records should be affected
|
||||
// List<Account> qualifying = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
// System.assertEquals(half, qualifying.size(), 'Only qualifying records should be processed');
|
||||
}
|
||||
|
||||
// ─── Governor Limit Tests ─────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldNotExceedGovernorLimits_WhenBulkProcessing() {
|
||||
// Given
|
||||
// List<Account> accounts = [SELECT Id FROM Account];
|
||||
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.heavyMethod(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(Limits.getDmlStatements() < Limits.getLimitDmlStatements(),
|
||||
'Should not exceed DML statement limit');
|
||||
System.assert(Limits.getQueries() < Limits.getLimitQueries(),
|
||||
'Should not exceed SOQL query limit');
|
||||
}
|
||||
|
||||
// ─── Helper Methods ───────────────────────────────────────────────────
|
||||
|
||||
// Add test-specific helper methods here
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @description Centralized factory for creating test data with sensible defaults.
|
||||
* All methods accept a doInsert flag for flexibility.
|
||||
* Bulk methods create multiple records; single-record methods delegate to bulk.
|
||||
* @author Generated by Apex Test Writer Skill
|
||||
*/
|
||||
@isTest
|
||||
public class TestDataFactory {
|
||||
|
||||
// ─── Accounts ─────────────────────────────────────────────────────────
|
||||
|
||||
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
|
||||
List<Account> accounts = new List<Account>();
|
||||
for (Integer i = 0; i < count; i++) {
|
||||
accounts.add(new Account(
|
||||
Name = 'Test Account ' + i,
|
||||
BillingCity = 'San Francisco',
|
||||
BillingState = 'CA',
|
||||
BillingCountry = 'USA',
|
||||
Industry = 'Technology',
|
||||
Type = 'Customer'
|
||||
));
|
||||
}
|
||||
if (doInsert) insert accounts;
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public static Account createAccount(Boolean doInsert) {
|
||||
return createAccounts(1, doInsert)[0];
|
||||
}
|
||||
|
||||
// ─── Contacts ─────────────────────────────────────────────────────────
|
||||
|
||||
public static List<Contact> createContacts(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Contact> contacts = new List<Contact>();
|
||||
Integer idx = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
contacts.add(new Contact(
|
||||
FirstName = 'Test',
|
||||
LastName = 'Contact ' + idx,
|
||||
Email = 'test.contact' + idx + '@example.com',
|
||||
AccountId = acc.Id
|
||||
));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert contacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
// ─── Opportunities ────────────────────────────────────────────────────
|
||||
|
||||
public static List<Opportunity> createOpportunities(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Opportunity> opps = new List<Opportunity>();
|
||||
Integer idx = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
opps.add(new Opportunity(
|
||||
Name = 'Test Opportunity ' + idx,
|
||||
AccountId = acc.Id,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000 + (idx * 1000)
|
||||
));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert opps;
|
||||
return opps;
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────
|
||||
|
||||
public static User createUser(String profileName, Boolean doInsert) {
|
||||
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
|
||||
String uniqueKey = String.valueOf(DateTime.now().getTime());
|
||||
|
||||
User u = new User(
|
||||
FirstName = 'Test',
|
||||
LastName = 'User ' + uniqueKey,
|
||||
Email = 'testuser' + uniqueKey + '@example.com',
|
||||
Username = 'testuser' + uniqueKey + '@example.com.test',
|
||||
Alias = 'tuser',
|
||||
TimeZoneSidKey = 'America/Los_Angeles',
|
||||
LocaleSidKey = 'en_US',
|
||||
EmailEncodingKey = 'UTF-8',
|
||||
LanguageLocaleKey = 'en_US',
|
||||
ProfileId = p.Id
|
||||
);
|
||||
if (doInsert) insert u;
|
||||
return u;
|
||||
}
|
||||
|
||||
// ─── Field Override Pattern ────────────────────────────────────────────
|
||||
|
||||
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
Industry = 'Technology'
|
||||
);
|
||||
for (String fieldName : fieldOverrides.keySet()) {
|
||||
acc.put(fieldName, fieldOverrides.get(fieldName));
|
||||
}
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ─── Custom Objects ───────────────────────────────────────────────────
|
||||
// Add methods for your custom objects following the same pattern:
|
||||
// public static List<MyObject__c> createMyObjects(Integer count, Boolean doInsert) { ... }
|
||||
}
|
||||
165
skills/generating-apex-test/references/assertion-patterns.md
Normal file
165
skills/generating-apex-test/references/assertion-patterns.md
Normal file
@ -0,0 +1,165 @@
|
||||
# Assertion Patterns
|
||||
|
||||
## Assertion Methods
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `System.assertEquals(expected, actual, msg)` | Exact equality |
|
||||
| `System.assertNotEquals(expected, actual, msg)` | Value should differ |
|
||||
| `System.assert(condition, msg)` | Boolean condition |
|
||||
|
||||
**Always include the third parameter (message)** - Makes test failures meaningful.
|
||||
|
||||
## Good vs Bad Assertions
|
||||
|
||||
### ❌ Bad: No message, tests coverage not behavior
|
||||
|
||||
```apex
|
||||
System.assertEquals(true, result);
|
||||
System.assert(accounts.size() > 0);
|
||||
```
|
||||
|
||||
### ✅ Good: Descriptive message, tests specific behavior
|
||||
|
||||
```apex
|
||||
System.assertEquals(true, result, 'Service should return true for valid input');
|
||||
System.assertEquals(200, accounts.size(), 'All 200 accounts should be processed');
|
||||
```
|
||||
|
||||
## Common Assertion Patterns
|
||||
|
||||
### Collection Size
|
||||
|
||||
```apex
|
||||
// Exact count
|
||||
System.assertEquals(200, results.size(), 'Should process all 200 records');
|
||||
|
||||
// Not empty
|
||||
System.assert(!results.isEmpty(), 'Results should not be empty');
|
||||
|
||||
// Empty
|
||||
System.assert(results.isEmpty(), 'No results expected for invalid input');
|
||||
```
|
||||
|
||||
### Field Values
|
||||
|
||||
```apex
|
||||
// Single record
|
||||
System.assertEquals('Processed', acc.Status__c, 'Account status should be updated to Processed');
|
||||
|
||||
// All records in collection
|
||||
for (Account acc : updatedAccounts) {
|
||||
System.assertEquals('Active', acc.Status__c,
|
||||
'Account ' + acc.Name + ' should have Active status');
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Testing
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldThrowException_WhenInputInvalid() {
|
||||
Boolean exceptionThrown = false;
|
||||
String exceptionMessage = '';
|
||||
|
||||
Test.startTest();
|
||||
try {
|
||||
MyService.process(null);
|
||||
} catch (MyCustomException e) {
|
||||
exceptionThrown = true;
|
||||
exceptionMessage = e.getMessage();
|
||||
}
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(exceptionThrown, 'MyCustomException should be thrown for null input');
|
||||
System.assert(exceptionMessage.contains('cannot be null'),
|
||||
'Exception message should mention null input');
|
||||
}
|
||||
```
|
||||
|
||||
### DML Results
|
||||
|
||||
```apex
|
||||
// Insert success
|
||||
Database.SaveResult[] results = Database.insert(accounts, false);
|
||||
for (Database.SaveResult sr : results) {
|
||||
System.assert(sr.isSuccess(), 'Insert should succeed: ' + sr.getErrors());
|
||||
}
|
||||
|
||||
// Expected failures
|
||||
Database.SaveResult sr = Database.insert(invalidAccount, false);
|
||||
System.assert(!sr.isSuccess(), 'Insert should fail for invalid data');
|
||||
System.assert(sr.getErrors()[0].getMessage().contains('REQUIRED_FIELD_MISSING'),
|
||||
'Error should indicate missing required field');
|
||||
```
|
||||
|
||||
### Comparing Objects
|
||||
|
||||
```apex
|
||||
// Compare specific fields, not entire objects
|
||||
System.assertEquals(expected.Name, actual.Name, 'Names should match');
|
||||
System.assertEquals(expected.Status__c, actual.Status__c, 'Status should match');
|
||||
|
||||
// Or use JSON for deep comparison (use sparingly)
|
||||
System.assertEquals(
|
||||
JSON.serialize(expected),
|
||||
JSON.serialize(actual),
|
||||
'Objects should be identical'
|
||||
);
|
||||
```
|
||||
|
||||
### Date/DateTime Assertions
|
||||
|
||||
```apex
|
||||
// Exact date
|
||||
System.assertEquals(Date.today(), record.CreatedDate__c, 'Should be created today');
|
||||
|
||||
// Date within range
|
||||
System.assert(record.DueDate__c >= Date.today(), 'Due date should be in the future');
|
||||
System.assert(record.DueDate__c <= Date.today().addDays(30),
|
||||
'Due date should be within 30 days');
|
||||
```
|
||||
|
||||
### Null Checks
|
||||
|
||||
```apex
|
||||
// Should be null
|
||||
System.assertEquals(null, result.ErrorMessage__c, 'No error expected for valid input');
|
||||
|
||||
// Should not be null
|
||||
System.assertNotEquals(null, result.Id, 'Record should have been inserted');
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Testing implementation, not behavior
|
||||
|
||||
```apex
|
||||
// Bad: Testing that a specific method was called
|
||||
System.assert(MyClass.methodWasCalled, 'Method should be called');
|
||||
|
||||
// Good: Testing the observable outcome
|
||||
System.assertEquals('Expected Value', record.Field__c, 'Field should be updated');
|
||||
```
|
||||
|
||||
### ❌ Overly generic assertions
|
||||
|
||||
```apex
|
||||
// Bad: Passes for any non-empty result
|
||||
System.assert(results.size() > 0);
|
||||
|
||||
// Good: Verifies exact expected count
|
||||
System.assertEquals(200, results.size(), 'All 200 records should be returned');
|
||||
```
|
||||
|
||||
### ❌ Missing negative test assertions
|
||||
|
||||
```apex
|
||||
// Bad: Only tests that no exception occurred
|
||||
MyService.process(data); // Test passes if no exception
|
||||
|
||||
// Good: Verifies the actual outcome
|
||||
Result r = MyService.process(data);
|
||||
System.assertEquals('Success', r.status, 'Processing should succeed');
|
||||
System.assertEquals(0, r.errorCount, 'No errors should occur');
|
||||
```
|
||||
276
skills/generating-apex-test/references/async-testing.md
Normal file
276
skills/generating-apex-test/references/async-testing.md
Normal file
@ -0,0 +1,276 @@
|
||||
# Async Testing Patterns
|
||||
|
||||
## Key Principle
|
||||
|
||||
`Test.stopTest()` forces all async operations to execute synchronously, allowing assertions on their results.
|
||||
|
||||
## Batch Apex Testing
|
||||
|
||||
### Basic Batch Test
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldProcessAllRecords_WhenBatchExecutes() {
|
||||
// Given: Create test data
|
||||
List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
|
||||
// When: Execute batch
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Id batchId = Database.executeBatch(batch, 200);
|
||||
Test.stopTest(); // Forces batch to complete
|
||||
|
||||
// Then: Verify results
|
||||
List<Account> updated = [SELECT Id, Status__c FROM Account];
|
||||
for (Account acc : updated) {
|
||||
System.assertEquals('Processed', acc.Status__c,
|
||||
'Batch should update all account statuses');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Batch with Failures
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldLogErrors_WhenRecordsFail() {
|
||||
// Given: Create mix of valid and invalid records
|
||||
List<Account> accounts = TestDataFactory.createAccounts(198, true);
|
||||
|
||||
// Create 2 accounts that will fail processing
|
||||
List<Account> invalidAccounts = new List<Account>();
|
||||
for (Integer i = 0; i < 2; i++) {
|
||||
invalidAccounts.add(new Account(
|
||||
Name = 'Invalid Account ' + i,
|
||||
Invalid_Field__c = 'triggers_validation_error'
|
||||
));
|
||||
}
|
||||
insert invalidAccounts;
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Database.executeBatch(batch, 50);
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
List<Error_Log__c> errors = [SELECT Id, Message__c FROM Error_Log__c];
|
||||
System.assertEquals(2, errors.size(), 'Should log 2 failed records');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Batch Scope
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldRespectBatchSize() {
|
||||
// Given
|
||||
List<Account> accounts = TestDataFactory.createAccounts(250, true);
|
||||
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Database.executeBatch(batch, 50); // 5 batches of 50
|
||||
Test.stopTest();
|
||||
|
||||
// Note: In tests, all batches execute but you can verify total processing
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(250, processed.size(), 'All records should be processed');
|
||||
}
|
||||
```
|
||||
|
||||
## Queueable Testing
|
||||
|
||||
### Basic Queueable Test
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldCompleteProcessing_WhenQueueableEnqueued() {
|
||||
// Given
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyQueueableClass queueable = new MyQueueableClass(acc.Id);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest(); // Forces queueable to complete
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id];
|
||||
System.assertEquals('Processed', updated.Status__c,
|
||||
'Queueable should update account status');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Queueable Chaining
|
||||
|
||||
Chained queueables only execute the first job in tests:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldChainNextJob_WhenMoreRecordsExist() {
|
||||
// Given: More records than one queueable can process
|
||||
List<Account> accounts = TestDataFactory.createAccounts(500, true);
|
||||
|
||||
Test.startTest();
|
||||
// First queueable processes batch 1 and chains next
|
||||
MyChainedQueueable queueable = new MyChainedQueueable(0, 100);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify first batch processed
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(100, processed.size(), 'First batch should process 100 records');
|
||||
|
||||
// Verify chain was enqueued (check AsyncApexJob)
|
||||
List<AsyncApexJob> jobs = [
|
||||
SELECT Id, Status, JobType
|
||||
FROM AsyncApexJob
|
||||
WHERE ApexClass.Name = 'MyChainedQueueable'
|
||||
];
|
||||
System.assert(jobs.size() >= 1, 'Chained job should be enqueued');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Queueable with Callouts
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldMakeCallout_WhenQueueableWithCallout() {
|
||||
// Given
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"status":"ok"}'));
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyQueueableWithCallout queueable = new MyQueueableWithCallout(acc.Id);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, External_Status__c FROM Account WHERE Id = :acc.Id];
|
||||
System.assertEquals('Synced', updated.External_Status__c,
|
||||
'Should update status after successful callout');
|
||||
}
|
||||
```
|
||||
|
||||
## Future Method Testing
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldExecuteFutureMethod() {
|
||||
// Given
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyClass.processFuture(acc.Id); // @future method
|
||||
Test.stopTest(); // Forces future to complete
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id];
|
||||
System.assertEquals(true, updated.Processed__c, 'Future should process record');
|
||||
}
|
||||
```
|
||||
|
||||
## Scheduled Apex Testing
|
||||
|
||||
### Testing Scheduled Execution
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldExecuteScheduledJob() {
|
||||
// Given
|
||||
List<Account> accounts = TestDataFactory.createAccounts(50, true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
String cronExp = '0 0 0 1 1 ? 2099'; // Arbitrary future time
|
||||
String jobId = System.schedule('Test Job', cronExp, new MyScheduledClass());
|
||||
|
||||
// Execute the scheduled job immediately
|
||||
MyScheduledClass scheduled = new MyScheduledClass();
|
||||
scheduled.execute(null); // Pass null SchedulableContext in tests
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(50, processed.size(), 'Scheduled job should process records');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Schedule Registration
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldScheduleJob() {
|
||||
Test.startTest();
|
||||
String cronExp = '0 0 6 * * ?'; // Daily at 6 AM
|
||||
String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass());
|
||||
Test.stopTest();
|
||||
|
||||
// Verify job is scheduled
|
||||
CronTrigger ct = [
|
||||
SELECT Id, CronExpression, State
|
||||
FROM CronTrigger
|
||||
WHERE Id = :jobId
|
||||
];
|
||||
System.assertEquals('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
|
||||
System.assertEquals('WAITING', ct.State, 'Job should be waiting');
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async Limits
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldNotExceedQueueableLimits() {
|
||||
// Given: Setup that might enqueue multiple jobs
|
||||
List<Account> accounts = TestDataFactory.createAccounts(100, true);
|
||||
|
||||
Test.startTest();
|
||||
Integer queueablesBefore = Limits.getQueueableJobs();
|
||||
|
||||
MyService.processWithQueueables(accounts);
|
||||
|
||||
Integer queueablesUsed = Limits.getQueueableJobs() - queueablesBefore;
|
||||
Test.stopTest();
|
||||
|
||||
// Verify limit not exceeded (50 in synchronous context, 1 in queueable)
|
||||
System.assert(queueablesUsed <= 50,
|
||||
'Should not exceed queueable limit. Used: ' + queueablesUsed);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Forgetting Test.stopTest()
|
||||
|
||||
```apex
|
||||
// Bad: Async never executes
|
||||
Test.startTest();
|
||||
System.enqueueJob(new MyQueueable());
|
||||
// Missing Test.stopTest()!
|
||||
|
||||
List<Account> results = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(100, results.size()); // FAILS - queueable didn't run
|
||||
```
|
||||
|
||||
### ❌ Testing chained jobs without understanding limits
|
||||
|
||||
```apex
|
||||
// Only the FIRST chained queueable runs in tests
|
||||
// Design tests to verify:
|
||||
// 1. First job completes correctly
|
||||
// 2. Chain is properly enqueued (check AsyncApexJob)
|
||||
// 3. Each job works independently
|
||||
```
|
||||
|
||||
### ❌ Not mocking callouts in async
|
||||
|
||||
```apex
|
||||
// Async with callouts MUST have mock set BEFORE Test.startTest()
|
||||
Test.setMock(HttpCalloutMock.class, new MockResponse()); // Before startTest!
|
||||
Test.startTest();
|
||||
System.enqueueJob(new QueueableWithCallout());
|
||||
Test.stopTest();
|
||||
```
|
||||
219
skills/generating-apex-test/references/mocking-patterns.md
Normal file
219
skills/generating-apex-test/references/mocking-patterns.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Mocking Patterns
|
||||
|
||||
## HTTP Callout Mocking
|
||||
|
||||
Apex doesn't allow real HTTP callouts in tests. Use `HttpCalloutMock` interface.
|
||||
|
||||
### Basic Mock Implementation
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class MockHttpResponse implements HttpCalloutMock {
|
||||
|
||||
private Integer statusCode;
|
||||
private String body;
|
||||
|
||||
public MockHttpResponse(Integer statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(statusCode);
|
||||
res.setBody(body);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Mock
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldProcessApiResponse_WhenCalloutSucceeds() {
|
||||
// Given
|
||||
String mockResponse = '{"status": "success", "data": [{"id": "123"}]}';
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, mockResponse));
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
List<ExternalRecord> results = MyIntegrationService.fetchRecords();
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
System.assertEquals(1, results.size(), 'Should parse one record from response');
|
||||
System.assertEquals('123', results[0].externalId, 'Should extract correct ID');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldHandleError_WhenCalloutFails() {
|
||||
// Given
|
||||
String errorResponse = '{"error": "Unauthorized"}';
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(401, errorResponse));
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
CalloutResult result = MyIntegrationService.fetchRecords();
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
System.assertEquals(false, result.isSuccess, 'Should indicate failure');
|
||||
System.assert(result.errorMessage.contains('Unauthorized'), 'Should capture error');
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Request Mock
|
||||
|
||||
For services making multiple callouts:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class MultiRequestMock implements HttpCalloutMock {
|
||||
|
||||
private Map<String, HttpResponse> endpointResponses;
|
||||
|
||||
public MultiRequestMock(Map<String, HttpResponse> responses) {
|
||||
this.endpointResponses = responses;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
String endpoint = req.getEndpoint();
|
||||
|
||||
for (String key : endpointResponses.keySet()) {
|
||||
if (endpoint.contains(key)) {
|
||||
return endpointResponses.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Default 404 if no match
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(404);
|
||||
res.setBody('{"error": "Not found"}');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
Map<String, HttpResponse> mocks = new Map<String, HttpResponse>();
|
||||
|
||||
HttpResponse authResponse = new HttpResponse();
|
||||
authResponse.setStatusCode(200);
|
||||
authResponse.setBody('{"token": "abc123"}');
|
||||
mocks.put('/oauth/token', authResponse);
|
||||
|
||||
HttpResponse dataResponse = new HttpResponse();
|
||||
dataResponse.setStatusCode(200);
|
||||
dataResponse.setBody('{"records": []}');
|
||||
mocks.put('/api/records', dataResponse);
|
||||
|
||||
Test.setMock(HttpCalloutMock.class, new MultiRequestMock(mocks));
|
||||
```
|
||||
|
||||
## StaticResourceCalloutMock
|
||||
|
||||
For complex response bodies, store JSON in Static Resources:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldParseComplexResponse() {
|
||||
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
|
||||
mock.setStaticResource('TestApiResponse'); // Static Resource name
|
||||
mock.setStatusCode(200);
|
||||
mock.setHeader('Content-Type', 'application/json');
|
||||
|
||||
Test.setMock(HttpCalloutMock.class, mock);
|
||||
|
||||
Test.startTest();
|
||||
Result r = MyService.callExternalApi();
|
||||
Test.stopTest();
|
||||
|
||||
System.assertNotEquals(null, r, 'Should parse response');
|
||||
}
|
||||
```
|
||||
|
||||
## Stub API (Enterprise Pattern)
|
||||
|
||||
For mocking Apex class dependencies using `System.StubProvider`:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class MyServiceMock implements System.StubProvider {
|
||||
|
||||
public Object handleMethodCall(
|
||||
Object stubbedObject,
|
||||
String stubbedMethodName,
|
||||
Type returnType,
|
||||
List<Type> paramTypes,
|
||||
List<String> paramNames,
|
||||
List<Object> args
|
||||
) {
|
||||
if (stubbedMethodName == 'getAccountData') {
|
||||
return new AccountData('Mock Account', 'Active');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in test:
|
||||
@isTest
|
||||
static void shouldUseAccountData() {
|
||||
MyServiceMock mockProvider = new MyServiceMock();
|
||||
IMyService mockService = (IMyService)Test.createStub(IMyService.class, mockProvider);
|
||||
|
||||
// Inject mock into class under test
|
||||
MyController controller = new MyController(mockService);
|
||||
|
||||
Test.startTest();
|
||||
String result = controller.displayAccountInfo();
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(result.contains('Mock Account'), 'Should use mocked data');
|
||||
}
|
||||
```
|
||||
|
||||
## Email Mocking
|
||||
|
||||
Apex sends real emails by default. Use limits to verify:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldSendEmail_WhenTriggered() {
|
||||
Integer emailsBefore = Limits.getEmailInvocations();
|
||||
|
||||
Test.startTest();
|
||||
MyService.sendNotification(testContact);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify email was queued (not actually sent in tests)
|
||||
System.assertEquals(
|
||||
emailsBefore + 1,
|
||||
Limits.getEmailInvocations(),
|
||||
'One email should be sent'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Platform Event Testing
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldPublishEvent_WhenRecordCreated() {
|
||||
Test.startTest();
|
||||
|
||||
// Enable event delivery in test context
|
||||
Test.enableChangeDataCapture();
|
||||
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// Deliver events
|
||||
Test.getEventBus().deliver();
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// Query platform event trigger results
|
||||
List<EventLog__c> logs = [SELECT Id FROM EventLog__c WHERE AccountId__c = :acc.Id];
|
||||
System.assertEquals(1, logs.size(), 'Event handler should create log record');
|
||||
}
|
||||
```
|
||||
176
skills/generating-apex-test/references/test-data-factory.md
Normal file
176
skills/generating-apex-test/references/test-data-factory.md
Normal file
@ -0,0 +1,176 @@
|
||||
# TestDataFactory Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
TestDataFactory is a centralized utility class for creating test records with sensible defaults. It ensures consistent test data across all test classes and reduces duplication.
|
||||
|
||||
## Base Template
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class TestDataFactory {
|
||||
|
||||
// ============ ACCOUNTS ============
|
||||
|
||||
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
|
||||
List<Account> accounts = new List<Account>();
|
||||
for (Integer i = 0; i < count; i++) {
|
||||
accounts.add(new Account(
|
||||
Name = 'Test Account ' + i,
|
||||
BillingStreet = '123 Test St',
|
||||
BillingCity = 'San Francisco',
|
||||
BillingState = 'CA',
|
||||
BillingPostalCode = '94105',
|
||||
BillingCountry = 'USA',
|
||||
Industry = 'Technology',
|
||||
Type = 'Customer'
|
||||
));
|
||||
}
|
||||
if (doInsert) insert accounts;
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public static Account createAccount(Boolean doInsert) {
|
||||
return createAccounts(1, doInsert)[0];
|
||||
}
|
||||
|
||||
// ============ CONTACTS ============
|
||||
|
||||
public static List<Contact> createContacts(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Contact> contacts = new List<Contact>();
|
||||
Integer index = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
contacts.add(new Contact(
|
||||
FirstName = 'Test',
|
||||
LastName = 'Contact ' + index,
|
||||
Email = 'test.contact' + index + '@example.com',
|
||||
Phone = '555-000-' + String.valueOf(index).leftPad(4, '0'),
|
||||
AccountId = acc.Id
|
||||
));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert contacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
// ============ OPPORTUNITIES ============
|
||||
|
||||
public static List<Opportunity> createOpportunities(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Opportunity> opps = new List<Opportunity>();
|
||||
Integer index = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
opps.add(new Opportunity(
|
||||
Name = 'Test Opportunity ' + index,
|
||||
AccountId = acc.Id,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000 + (index * 1000)
|
||||
));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert opps;
|
||||
return opps;
|
||||
}
|
||||
|
||||
// ============ USERS ============
|
||||
|
||||
public static User createUser(String profileName, Boolean doInsert) {
|
||||
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
|
||||
String uniqueKey = String.valueOf(DateTime.now().getTime());
|
||||
|
||||
User u = new User(
|
||||
FirstName = 'Test',
|
||||
LastName = 'User ' + uniqueKey,
|
||||
Email = 'testuser' + uniqueKey + '@example.com',
|
||||
Username = 'testuser' + uniqueKey + '@example.com.test',
|
||||
Alias = 'tuser',
|
||||
TimeZoneSidKey = 'America/Los_Angeles',
|
||||
LocaleSidKey = 'en_US',
|
||||
EmailEncodingKey = 'UTF-8',
|
||||
LanguageLocaleKey = 'en_US',
|
||||
ProfileId = p.Id
|
||||
);
|
||||
if (doInsert) insert u;
|
||||
return u;
|
||||
}
|
||||
|
||||
// ============ CUSTOM OBJECTS ============
|
||||
|
||||
// Add methods for your custom objects following the same pattern:
|
||||
// public static List<MyObject__c> createMyObjects(Integer count, Boolean doInsert) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Field Override Pattern
|
||||
|
||||
Allow callers to override default values:
|
||||
|
||||
```apex
|
||||
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
Industry = 'Technology'
|
||||
);
|
||||
|
||||
// Apply overrides
|
||||
for (String fieldName : fieldOverrides.keySet()) {
|
||||
acc.put(fieldName, fieldOverrides.get(fieldName));
|
||||
}
|
||||
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Usage:
|
||||
Account acc = TestDataFactory.createAccount(new Map<String, Object>{
|
||||
'Name' => 'Custom Name',
|
||||
'Industry' => 'Healthcare'
|
||||
}, true);
|
||||
```
|
||||
|
||||
## Handling Required Fields and Validation Rules
|
||||
|
||||
```apex
|
||||
public static Account createAccountWithRequiredFields(Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
// Required custom fields
|
||||
External_Id__c = 'EXT-' + String.valueOf(DateTime.now().getTime()),
|
||||
// Fields required by validation rules
|
||||
Phone = '555-123-4567',
|
||||
Website = 'https://example.com'
|
||||
);
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## Record Type Support
|
||||
|
||||
```apex
|
||||
public static Account createAccountByRecordType(String recordTypeName, Boolean doInsert) {
|
||||
Id recordTypeId = Schema.SObjectType.Account
|
||||
.getRecordTypeInfosByDeveloperName()
|
||||
.get(recordTypeName)
|
||||
.getRecordTypeId();
|
||||
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
RecordTypeId = recordTypeId
|
||||
);
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always include doInsert parameter** - Allows flexibility for tests that need to modify records before insert
|
||||
2. **Use unique identifiers** - Include index or timestamp in Name/Email fields to avoid duplicates
|
||||
3. **Set all required fields** - Include all fields required by validation rules
|
||||
4. **Return the created records** - Enables chaining and further manipulation
|
||||
5. **Create bulk methods first** - Single record methods should call bulk methods with count=1
|
||||
267
skills/generating-apex/SKILL.md
Normal file
267
skills/generating-apex/SKILL.md
Normal file
@ -0,0 +1,267 @@
|
||||
---
|
||||
name: generating-apex
|
||||
description: >
|
||||
Generate production-ready Apex classes for Salesforce following enterprise
|
||||
best practices. Covers all standard class types: service, selector, domain,
|
||||
batch, queueable, schedulable, DTO, utility, interface, abstract, and custom
|
||||
exception classes. Also applies when refactoring or restructuring existing
|
||||
Apex classes.
|
||||
|
||||
SCOPE: Apex classes only. Does not apply to triggers or unit tests.
|
||||
|
||||
Trigger on any request to create, write, build, generate, or scaffold an
|
||||
Apex class — even when the user doesn't say "Apex class" explicitly.
|
||||
Activate on phrases like: "build a service for," "create a handler,"
|
||||
"I need a class that," "write Apex to," "scaffold a batch job,"
|
||||
"create a queueable," or any description of Salesforce server-side logic
|
||||
that implies a class solution.
|
||||
---
|
||||
|
||||
# Apex Class
|
||||
|
||||
Generate well-structured, production-ready Apex classes for Salesforce development.
|
||||
|
||||
## Scope
|
||||
|
||||
**Generates:**
|
||||
- Service classes (business logic layer)
|
||||
- Selector / query classes (SOQL encapsulation)
|
||||
- Domain classes (SObject-specific logic)
|
||||
- Batch Apex classes (`Database.Batchable`)
|
||||
- Queueable Apex classes (`Queueable`, optionally `Finalizer`)
|
||||
- Schedulable Apex classes (`Schedulable`)
|
||||
- DTO / wrapper / request-response classes
|
||||
- Utility / helper classes
|
||||
- Custom exception classes
|
||||
- Interfaces and abstract classes
|
||||
|
||||
**Does NOT generate:**
|
||||
- Triggers (use a trigger framework skill)
|
||||
- Unit tests (use a test writer skill)
|
||||
- Aura controllers
|
||||
- LWC JavaScript controllers
|
||||
|
||||
---
|
||||
|
||||
## Gathering Requirements
|
||||
|
||||
Before generating code, gather these inputs from the user (ask if not provided):
|
||||
|
||||
1. **Class type** — Service, Selector, Batch, Queueable, Schedulable, Domain, DTO, Utility, Interface, Abstract, Exception
|
||||
2. **Class name** — or enough context to derive a meaningful name
|
||||
3. **SObject(s) involved** — if applicable
|
||||
4. **Business requirements** — plain-language description of what the class should do
|
||||
5. **Optional preferences:**
|
||||
- Sharing model (`with sharing`, `without sharing`, `inherited sharing`)
|
||||
- Access modifier (`public` or `global`)
|
||||
- API version (default: `62.0`)
|
||||
- Whether to include ApexDoc comments (default: yes)
|
||||
|
||||
If the user provides a clear, complete request, generate immediately without unnecessary back-and-forth.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards — ALWAYS Follow These
|
||||
|
||||
### Separation of Concerns
|
||||
- **Service classes** contain business logic. They call Selectors for data and may call Domain classes for SObject-specific behavior.
|
||||
- **Selector classes** encapsulate all SOQL queries. Services never contain inline SOQL.
|
||||
- **Domain classes** encapsulate SObject-specific logic (field defaults, validation, transformation).
|
||||
- Keep classes focused on a single responsibility.
|
||||
|
||||
### Bulkification
|
||||
- All methods must operate on collections (`List`, `Set`, `Map`) by default.
|
||||
- Never accept a single SObject when a `List<SObject>` is appropriate.
|
||||
- Provide convenience overloads for single-record callers only when it makes the API cleaner, and have them delegate to the bulk method.
|
||||
|
||||
### Governor Limit Safety
|
||||
- **No SOQL or DML inside loops.** Ever.
|
||||
- Collect IDs/records first, query/DML once outside the loop.
|
||||
- Use `Limits` class checks in batch/bulk operations where appropriate.
|
||||
- Prefer `Database.insert(records, false)` with error handling in batch contexts.
|
||||
|
||||
### Sharing Model
|
||||
- Default to `with sharing` unless the user specifies otherwise.
|
||||
- If `without sharing` or `inherited sharing` is used, add an ApexDoc `@description` comment explaining WHY.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Class 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`, `DateUtil` |
|
||||
| Interface | `I{Descriptive}` | `INotificationService` |
|
||||
| Abstract | `Abstract{Descriptive}` | `AbstractIntegrationService` |
|
||||
| Exception | `{Descriptive}Exception` | `AccountServiceException` |
|
||||
|
||||
### ApexDoc Comments
|
||||
Include ApexDoc on every `public` and `global` method and on the class itself:
|
||||
|
||||
```apex
|
||||
/**
|
||||
* @description Brief description of what the class does
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
```
|
||||
|
||||
```apex
|
||||
/**
|
||||
* @description Brief description of what the method does
|
||||
* @param paramName Description of the parameter
|
||||
* @return Description of the return value
|
||||
* @example
|
||||
* List<Account> results = AccountService.deduplicateAccounts(accountIds);
|
||||
*/
|
||||
```
|
||||
|
||||
### Null Safety
|
||||
- Use guard clauses at the top of methods for null/empty inputs.
|
||||
- Use safe navigation (`?.`) where appropriate.
|
||||
- Return empty collections rather than `null`.
|
||||
|
||||
### Constants
|
||||
- No magic strings or numbers in logic.
|
||||
- Use `private static final` constants or a dedicated constants class.
|
||||
- Use `Label.` custom labels for user-facing strings when appropriate.
|
||||
|
||||
### Custom Exceptions
|
||||
- Each service class should define or reference a corresponding custom exception.
|
||||
- Inner exception classes are preferred for simple cases: `public class AccountServiceException extends Exception {}`
|
||||
- Include meaningful error messages with context.
|
||||
|
||||
### Error Handling
|
||||
- Catch specific exceptions, not generic `Exception` unless re-throwing.
|
||||
- Log errors meaningfully (assume a logging utility exists or stub one).
|
||||
- In batch contexts, use `Database.SaveResult` / `Database.UpsertResult` with partial success.
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
For every class, produce TWO files:
|
||||
|
||||
1. **`{ClassName}.cls`** — The Apex class source code
|
||||
2. **`{ClassName}.cls-meta.xml`** — The metadata file
|
||||
|
||||
### Meta XML Template
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>{API_VERSION}</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
```
|
||||
|
||||
Default `apiVersion` is `62.0` unless the user specifies otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Class Type–Specific Instructions
|
||||
|
||||
### Service Classes
|
||||
- Read and follow: `assets/service.cls`
|
||||
- Stateless — no instance variables holding mutable state
|
||||
- All public methods should be `static` unless there's a compelling reason for instance methods
|
||||
- Delegate queries to a Selector class
|
||||
- Wrap business logic errors in a custom exception
|
||||
|
||||
### Selector Classes
|
||||
- Read and follow: `assets/selector.cls`
|
||||
- One Selector per SObject (or per logical query domain)
|
||||
- Return `List<SObject>` or `Map<Id, SObject>`
|
||||
- Accept filter criteria as method parameters, not hardcoded
|
||||
- Include a private method that returns the base field list to keep DRY
|
||||
|
||||
### Domain Classes
|
||||
- Read and follow: `assets/domain.cls`
|
||||
- Encapsulate field-level defaults, derivations, and validations
|
||||
- Operate on `List<SObject>` — designed to be called from triggers or services
|
||||
- No SOQL or DML — only in-memory SObject manipulation
|
||||
|
||||
### Batch Classes
|
||||
- Read and follow: `assets/batch.cls`
|
||||
- Implement `Database.Batchable<SObject>` and optionally `Database.Stateful`
|
||||
- Use `Database.QueryLocator` in `start()` for large datasets
|
||||
- Handle partial failures in `execute()` using `Database.SaveResult`
|
||||
- Implement meaningful `finish()` — at minimum, log completion
|
||||
|
||||
### Queueable Classes
|
||||
- Read and follow: `assets/queueable.cls`
|
||||
- Implement `Queueable` and optionally `Database.AllowsCallouts`
|
||||
- Accept data through the constructor — queueables are stateful
|
||||
- For chaining, include guard logic to prevent infinite chains
|
||||
- Optionally implement `Finalizer` for error recovery
|
||||
|
||||
### Schedulable Classes
|
||||
- Read and follow: `assets/schedulable.cls`
|
||||
- Implement `Schedulable`
|
||||
- Keep `execute()` lightweight — delegate to a Batch or Queueable
|
||||
- Include a static method that returns a CRON expression for convenience
|
||||
- Document the expected schedule in ApexDoc
|
||||
|
||||
### DTO / Wrapper Classes
|
||||
- Read and follow: `assets/dto.cls`
|
||||
- Use `public` properties — no getters/setters unless validation is needed
|
||||
- Include a no-arg constructor and optionally a parameterized constructor
|
||||
- Implement `Comparable` if sorting is needed
|
||||
- Keep them serialization-friendly (no transient state unless intentional)
|
||||
|
||||
### Utility Classes
|
||||
- Read and follow: `assets/utility.cls`
|
||||
- All methods `public static`
|
||||
- Class should be `public with sharing` with a `private` constructor to prevent instantiation
|
||||
- Group related utilities (e.g., `StringUtil`, `DateUtil`, `CollectionUtil`)
|
||||
- Every method must be side-effect-free (no DML, no SOQL)
|
||||
|
||||
### Interfaces
|
||||
- Read and follow: `assets/interface.cls`
|
||||
- Define the contract clearly with ApexDoc on every method signature
|
||||
- Use meaningful names that describe the capability: `INotificationService`, `IRetryable`
|
||||
|
||||
### Abstract Classes
|
||||
- Read and follow: `assets/abstract.cls`
|
||||
- Provide default implementations for common behavior
|
||||
- Mark extension points as `protected virtual` or `protected abstract`
|
||||
- Include a concrete example in the ApexDoc showing how to extend
|
||||
|
||||
### Custom Exceptions
|
||||
- Read and follow: `assets/exception.cls`
|
||||
- Extend `Exception`
|
||||
- Keep them simple — Apex exceptions don't support custom constructors well
|
||||
- Name them descriptively: `AccountServiceException`, `IntegrationTimeoutException`
|
||||
|
||||
---
|
||||
|
||||
## Generation Workflow
|
||||
|
||||
1. Determine the class type from the user's request
|
||||
2. Read the corresponding template from `assets/`
|
||||
3. Read relevant examples from `references/` if the class type has one
|
||||
4. Apply the user's requirements to the template pattern
|
||||
5. Generate the `.cls` file with full ApexDoc
|
||||
6. Generate the `.cls-meta.xml` file
|
||||
7. Present both files to the user
|
||||
8. Include a brief note on design decisions if any non-obvious choices were made
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- ❌ SOQL or DML inside loops
|
||||
- ❌ Hardcoded IDs or record type names (use `Schema.SObjectType` or Custom Metadata)
|
||||
- ❌ God classes that mix query + logic + DML
|
||||
- ❌ `public` fields on service classes
|
||||
- ❌ Returning `null` from methods that should return collections
|
||||
- ❌ Generic `catch (Exception e)` without re-throwing or meaningful handling
|
||||
- ❌ Business logic in Batch `start()` methods
|
||||
- ❌ Tight coupling between classes — use interfaces for extensibility
|
||||
- ❌ Magic strings or numbers
|
||||
- ❌ Methods longer than ~40 lines — break them into private helpers
|
||||
128
skills/generating-apex/assets/abstract.cls
Normal file
128
skills/generating-apex/assets/abstract.cls
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @description Abstract base class for {describe the family of classes this serves}.
|
||||
* Provides common behavior and defines extension points for subclasses.
|
||||
* Subclasses must implement the abstract methods to provide specific behavior.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Initializes the base class with default configuration
|
||||
*/
|
||||
protected {ClassName}() {
|
||||
this.timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ─── Abstract Methods (must be implemented by subclasses) ────────────
|
||||
|
||||
/**
|
||||
* @description Returns the endpoint URL for this integration.
|
||||
* Subclasses must provide their specific endpoint.
|
||||
* @return The endpoint URL as a String
|
||||
*/
|
||||
protected abstract String getEndpoint();
|
||||
|
||||
/**
|
||||
* @description 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) ───────────────
|
||||
|
||||
/**
|
||||
* @description 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
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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()'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 {}
|
||||
}
|
||||
125
skills/generating-apex/assets/batch.cls
Normal file
125
skills/generating-apex/assets/batch.cls
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @description Batch Apex class for {describe the batch operation}.
|
||||
* Processes {SObject} records in configurable batch sizes.
|
||||
* Implements Database.Stateful to track cumulative results across chunks.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Default constructor
|
||||
*/
|
||||
public {ClassName}() {
|
||||
// Default configuration
|
||||
}
|
||||
|
||||
// ─── Batchable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
}
|
||||
102
skills/generating-apex/assets/domain.cls
Normal file
102
skills/generating-apex/assets/domain.cls
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @description Domain class for {SObject}.
|
||||
* Encapsulates field-level defaults, derivations, and validations.
|
||||
* Operates only on in-memory SObject data — no SOQL or DML.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Domain {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
// TODO: Add constants for default values, statuses, etc.
|
||||
|
||||
// ─── Field Defaults ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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
|
||||
}
|
||||
108
skills/generating-apex/assets/dto.cls
Normal file
108
skills/generating-apex/assets/dto.cls
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @description 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.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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 ──────────────────────────────────────────────────────
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public String name { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public Id recordId { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public Boolean isActive { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public List<String> tags { get; set; }
|
||||
|
||||
// TODO: Add additional properties as needed
|
||||
|
||||
// ─── Constructors ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description No-arg constructor for deserialization compatibility
|
||||
*/
|
||||
public {ClassName}() {
|
||||
this.tags = new List<String>();
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Serializes this DTO to a JSON string
|
||||
* @return JSON representation of this DTO
|
||||
*/
|
||||
public String toJson() {
|
||||
return JSON.serialize(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
}
|
||||
51
skills/generating-apex/assets/exception.cls
Normal file
51
skills/generating-apex/assets/exception.cls
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @description 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.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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 + ')';
|
||||
// }
|
||||
// }
|
||||
}
|
||||
25
skills/generating-apex/assets/interface.cls
Normal file
25
skills/generating-apex/assets/interface.cls
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @description Interface for {describe the capability or contract this interface defines}.
|
||||
* Implement this interface to provide {describe what implementations do}.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* public class EmailNotificationService implements {InterfaceName} {
|
||||
* public void execute(Map<String, Object> params) {
|
||||
* // Implementation
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public interface {InterfaceName} {
|
||||
|
||||
/**
|
||||
* @description {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);
|
||||
}
|
||||
92
skills/generating-apex/assets/queueable.cls
Normal file
92
skills/generating-apex/assets/queueable.cls
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @description Queueable Apex class for {describe the async operation}.
|
||||
* Accepts data through the constructor for stateful processing.
|
||||
* Optionally implements Database.AllowsCallouts for external integrations.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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
|
||||
}
|
||||
}
|
||||
75
skills/generating-apex/assets/schedulable.cls
Normal file
75
skills/generating-apex/assets/schedulable.cls
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @description 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.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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
|
||||
|
||||
/** @description Runs daily at 2:00 AM */
|
||||
public static final String CRON_DAILY_2AM = '0 0 2 * * ?';
|
||||
|
||||
/** @description Runs every weekday at 6:00 AM */
|
||||
public static final String CRON_WEEKDAYS_6AM = '0 0 6 ? * MON-FRI';
|
||||
|
||||
/** @description Runs hourly at the top of the hour */
|
||||
public static final String CRON_HOURLY = '0 0 * * * ?';
|
||||
|
||||
// ─── Schedulable Interface ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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}()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
skills/generating-apex/assets/selector.cls
Normal file
92
skills/generating-apex/assets/selector.cls
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @description Selector class for {SObject} queries.
|
||||
* Encapsulates all SOQL for {SObject} records.
|
||||
* All methods return bulkified results (Lists or Maps).
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Selector {
|
||||
|
||||
// ─── Field Lists ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for query errors
|
||||
*/
|
||||
public class QueryException extends Exception {}
|
||||
}
|
||||
69
skills/generating-apex/assets/service.cls
Normal file
69
skills/generating-apex/assets/service.cls
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @description Service class for {SObject} business logic.
|
||||
* Follows separation of concerns: delegates queries to {SObject}Selector
|
||||
* and SObject manipulation to {SObject}Domain where applicable.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Service {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description {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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for {SObject}Service errors
|
||||
*/
|
||||
public class {SObject}ServiceException extends Exception {}
|
||||
}
|
||||
97
skills/generating-apex/assets/utility.cls
Normal file
97
skills/generating-apex/assets/utility.cls
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @description 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.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {ClassName} {
|
||||
|
||||
// ─── Private Constructor ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Prevents instantiation — use static methods only
|
||||
*/
|
||||
@TestVisible
|
||||
private {ClassName}() {
|
||||
// Utility class — do not instantiate
|
||||
}
|
||||
|
||||
// ─── Public Methods ──────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add utility methods below. Examples:
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
}
|
||||
148
skills/generating-apex/references/AccountDeduplicationBatch.cls
Normal file
148
skills/generating-apex/references/AccountDeduplicationBatch.cls
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @description 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.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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);
|
||||
}
|
||||
}
|
||||
193
skills/generating-apex/references/AccountSelector.cls
Normal file
193
skills/generating-apex/references/AccountSelector.cls
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @description Selector class for Account queries.
|
||||
* Encapsulates all SOQL for Account records.
|
||||
* All methods return bulkified results (Lists or Maps).
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class AccountSelector {
|
||||
|
||||
// ─── Field Lists ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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'
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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
|
||||
];
|
||||
}
|
||||
}
|
||||
201
skills/generating-apex/references/AccountService.cls
Normal file
201
skills/generating-apex/references/AccountService.cls
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @description Service class for Account business logic.
|
||||
* Provides account deduplication, enrichment, and territory assignment.
|
||||
* Delegates queries to AccountSelector and SObject manipulation to AccountDomain.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for AccountService errors
|
||||
*/
|
||||
public class AccountServiceException extends Exception {}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user