Series Note: This article is Part 4 of our hands-on engineering series: Building the Salesforce Appointment Scheduler. Follow along as we design and implement a multi-location scheduling platform on Salesforce using enterprise patterns and developer-first tooling.
Why Service Layer Architecture Matters in Salesforce
In many Salesforce implementations, business logic ends up scattered across triggers, controller methods, and helper utilities. This architectural drift leads to familiar symptoms:
- Logic Duplication: The same validation rule is rewritten in an Apex trigger, an LWC controller, and a batch job.
- Untestable Code: Methods tightly coupled to visual components cannot be tested in isolation.
- Governor Limit Surprises: Unoptimized queries in multiple layers burn SOQL and DML limits unexpectedly.
To prevent this, the Salesforce Appointment Scheduler adheres to an enterprise Service Layer Pattern. Domain logic lives strictly inside dedicated, reusable service classes that can be invoked equally by Lightning Web Components, Apex triggers, or REST endpoints.
In Part 4, we build the four core services driving our application, applying strict Test-Driven Development (TDD) using Hob: The Salesforce House-Elf (hob create apex --with-test).
Scaffolding Companion Services & Tests with Hob
Writing a service class and remembering to manually create its companion test file, metadata XMLs, and test scaffolds is repetitive. Hob automates this with the --with-test flag:
hob create apex CustomerService --with-test
hob create apex LocationService --with-test
hob create apex AvailabilityService --with-test
hob create apex AppointmentService --with-test
The Terminal Output
✨ Hob - The Salesforce House-Elf ✨
"Loyal, quiet, and tireless assistance for your Salesforce Development."
✔ Created Apex class 'CustomerService'
✔ Created Apex test class 'CustomerServiceTest'
🪄 Hob crafted your Apex class with its test companion:
Class: force-app/main/default/classes/CustomerService.cls
Test Class: force-app/main/default/classes/CustomerServiceTest.cls
With each command, Hob scaffolds:
CustomerService.clsand its.cls-meta.xmlmetadata descriptor.CustomerServiceTest.clspre-configured with@IsTest,@TestSetup, and baseline test methods using modernAssert.areEqual()syntax.
The Four Core Domain Services
+-----------------------+ +-----------------------+
| CustomerService | | LocationService |
| - Validation | | - Skill filtering |
| - RecordType lookup | | - Proximity ranking |
| - Quick registration | | - Haversine fallback |
+-----------------------+ +-----------------------+
^ ^
| |
+------------------------------------------------------+
| AvailabilityService |
| - Real-time slot calculation algorithm |
| - Shift evaluation (Full-time & Part-time) |
| - Conflict detection against existing bookings |
+------------------------------------------------------+
|
v
+------------------------------------------------------+
| AppointmentService |
| - Concurrency protection via FOR UPDATE row locking |
| - Double-booking validation |
| - Lifecycle management & Cancellation |
+------------------------------------------------------+
1. CustomerService: Search, Validation & Registration
The customer entry point supports call center operators searching for existing clients or registering new ones on the fly.
Key Capabilities
- Search Queries: Dynamic SOSL/SOQL search querying Name, Email, Phone, and Postal Code.
- RecordType Enforcement: Queries the schema for the
CustomerRecord Type ID to guarantee contacts are categorized properly. - Inline Registration: Exposes an
@AuraEnabledmethod allowing the LWC frontend wizard to create and immediately return a new customer in a single transaction.
@AuraEnabled
public static Contact createCustomer(
String firstName, String lastName, String email, String phone,
String street, String city, String postalCode, String country,
Decimal latitude, Decimal longitude
) {
if (String.isBlank(lastName)) {
throw new CustomerServiceException('Last name is required to create a customer.');
}
Contact customer = new Contact(
FirstName = firstName,
LastName = lastName,
Email = email,
Phone = phone,
RecordTypeId = customerRecordTypeId,
MailingStreet = street,
MailingCity = city,
MailingPostalCode = postalCode,
MailingCountry = country,
MailingLatitude = latitude,
MailingLongitude = longitude
);
insert customer;
return getCustomerById(customer.Id);
}
2. LocationService: Proximity Ranking & Skill Filtering
Not every branch provides every service. A branch is only eligible if at least one active staff member is certified for the requested Service_Type__c.
Key Capabilities
- Staff Skill Evaluation: Filters branches through the
Staff_Service_Type__cjunction, ensuring only branches with certified staff appear in the selection list. - Proximity Ranking: Ranks branches by proximity to the customer’s coordinates.
- Haversine Distance Calculator: Provides a mathematically sound great-circle distance algorithm (
calculateHaversineDistanceKm) as a reliable fallback when geocoding data integration rules are inactive in scratch orgs or offline:
public static Double calculateHaversineDistanceKm(Decimal lat1, Decimal lon1, Decimal lat2, Decimal lon2) {
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) return null;
Double earthRadiusKm = 6371.0;
Double dLat = toRadians(Double.valueOf(lat2 - lat1));
Double dLon = toRadians(Double.valueOf(lon2 - lon1));
Double rLat1 = toRadians(Double.valueOf(lat1));
Double rLat2 = toRadians(Double.valueOf(lat2));
Double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(rLat1) * Math.cos(rLat2) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
Double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round((earthRadiusKm * c) * 10.0) / 10.0;
}
3. AvailabilityService: Real-Time Slot Calculation & Shift Intelligence
The central scheduling challenge is determining when appointments can take place. Availability cannot be a static table; it must be calculated dynamically.
The Algorithm
1. Fetch Service Duration (e.g. 60 minutes).
2. Determine Day of Week for targetDate (DateTime.format('EEEE')).
3. Identify Qualified Staff at the selected branch.
4. Retrieve Staff Working Hours (Staff_Working_Hours__c) for that day of the week.
5. Retrieve Existing Bookings (Appointment__c) on targetDate for these staff members.
6. Discretize the shift window into slots of durationMinutes.
7. Discard slots that overlap with existing bookings (slotStart < aptEnd && slotEnd > aptStart).
8. Return sorted, non-conflicting TimeSlot objects.
By supporting both full-time (Mon–Fri 09:00–17:00) and part-time shift patterns (morning, afternoon, alternate days), the engine accurately reflects real-world staffing.
4. AppointmentService: Concurrency Locking & Double-Booking Protection
In high-concurrency environments, two operators might attempt to book the last remaining slot for the same technician at the same millisecond. Without database locking, both bookings would succeed, creating an embarrassing double-booking.
AppointmentService guards against this using Salesforce database row locking (FOR UPDATE):
// Concurrency Lock & Staff Double-Booking Check
List<Appointment__c> staffConflicts = [
SELECT Id, Name, Start_Time__c, End_Time__c
FROM Appointment__c
WHERE Staff__c = :staffId
AND Status__c != 'Cancelled'
AND Start_Time__c < :endTime
AND End_Time__c > :startTime
FOR UPDATE
];
if (!staffConflicts.isEmpty()) {
throw new AppointmentBookingException('The selected staff member has a scheduling conflict with another appointment.');
}
It additionally validates that the customer is not simultaneously booked into another appointment and provides a clean cancelAppointment lifecycle method.
Putting Our Part 3 Factories to Work
One of the greatest benefits of this architecture is how expressive and clean our unit tests become. Thanks to the test data factories scaffolded in Part 3, our tests contain zero messy SOQL setup or boilerplate DML:
@TestSetup
static void setupTestData() {
Account branch = AccountDataFactory.createAccount(new Map<String, Object>{ 'Name' => 'Bristol Hub' });
Contact customer1 = ContactDataFactory.createContact(new Map<String, Object>{ 'FirstName' => 'Clara', 'LastName' => 'Oswald' });
Contact customer2 = ContactDataFactory.createContact(new Map<String, Object>{ 'FirstName' => 'Amy', 'LastName' => 'Pond' });
Contact staff = ContactDataFactory.createStaffContact(branch.Id, new Map<String, Object>{ 'FirstName' => 'Rory', 'LastName' => 'Williams' });
Service_Type__c service = Service_TypeDataFactory.createService_Type(new Map<String, Object>{ 'Name' => 'Standard Service', 'Duration_Minutes__c' => 60 });
Staff_Service_TypeDataFactory.createStaffServiceType(staff.Id, service.Id, null);
}
Unit tests across all four services execute cleanly, providing comprehensive coverage across positive paths, validation errors, and race-condition simulations.
Security: Updating Appointment_Scheduler_Operator
To ensure non-admin users can invoke these services from Lightning Experience and LWC components, we updated our permission set with explicit <classAccesses>:
<classAccesses>
<apexClass>AppointmentService</apexClass>
<enabled>true</enabled>
</classAccesses>
<classAccesses>
<apexClass>AvailabilityService</apexClass>
<enabled>true</enabled>
</classAccesses>
<classAccesses>
<apexClass>CustomerService</apexClass>
<enabled>true</enabled>
</classAccesses>
<classAccesses>
<apexClass>LocationService</apexClass>
<enabled>true</enabled>
</classAccesses>
DX Verdict & Tool Feedback
What Shined
- The
--with-testHabit: Having the CLI generate companion test files automatically encourages writing unit tests alongside domain logic, lowering the activation energy for TDD. - Modern Assert Syntax: Hob scaffolds test classes with modern
Assert.areEqual()andAssert.isTrue()syntax rather than deprecatedSystem.assertcalls.
Opportunities for Hob’s Backlog
hob create apex --serviceor--aura-enabled: Adding flags to pre-populate@AuraEnabled(cacheable=true)method signatures or standard service exception classes would accelerate service layer scaffolding even further.
The Appointment Scheduler Series
- Series Index: Building the Salesforce Appointment Scheduler: Architecture & Series Index
- ← Part 1: Eliminating Salesforce XML Fatigue — CLI Schema Scaffolding with Hob
- ← Part 2: Instant Least-Privilege Access — Generating Permission Sets with Hob
- ← Part 3: Standardizing Test Hygiene — In-Memory vs DML Factories with Hob
- Part 4: High-Velocity TDD — Companion Test Scaffolding with Hob (Current Article)
- Part 5: Architecture by Default — Separation of Concerns with Hob Triggers →
- Part 6: Building Modern Lightning UIs Fast — Scaffolded LWCs with Hob
- Part 7: One Command to Rule Them All — The Complete Dev Org Setup with Hob