Advanced 20 min read

Controller/Service/Selector Patterns for LWC

By the end of this lesson, you'll be able to:

  • Explain the Controller/Service/Selector layering pattern for Apex backing LWC
  • Distinguish what belongs in each layer
  • Recognize why this layering enables independent testing and reuse

Prerequisites: "Separation of Concerns: UI vs. Business Logic"

The Three Layers

  • Controller — thin, @AuraEnabled-only. Its job is the LWC-facing contract: receiving parameters, calling into the Service layer, wrapping the result in a DTO (Module 7). No real business logic lives here.
  • Service — the actual business logic and orchestration. A plain Apex class with no @AuraEnabled annotations at all — it doesn't know or care that LWC exists, which is exactly what makes it independently unit-testable and reusable.
  • Selector — isolates the actual SOQL query construction. Query logic can change (new filters, different fields) without touching the business logic that consumes its results.

Why Layer This Way

Each layer can be tested and changed independently — a query change in the Selector doesn't risk breaking business logic in the Service, and vice versa. More importantly, the same Service/Selector logic can be reused by more than one Controller — for instance, one exposed to LWC and a completely separate one used by a scheduled batch job — without duplicating the actual business rules in two places.

A Worked Example

// Selector — isolates query construction
public inherited sharing class AccountSelector {
    public static List<Account> findByNameLike(String searchTerm) {
        return Database.query(
            'SELECT Id, Name, (SELECT Id FROM Opportunities WHERE IsClosed = false) ' +
            'FROM Account WHERE Name LIKE :searchTerm',
            AccessLevel.USER_MODE
        );
    }
}

// Service — the actual business logic, no @AuraEnabled at all
public inherited sharing class AccountService {
    public static List<AccountResult> search(String searchTerm) {
        List<AccountResult> results = new List<AccountResult>();
        for (Account acc : AccountSelector.findByNameLike('%' + searchTerm + '%')) {
            results.add(new AccountResult(acc.Id, acc.Name, acc.Opportunities.size()));
        }
        return results;
    }
}

// Controller — thin, the only class @AuraEnabled-annotated
public with sharing class AccountController {
    @AuraEnabled(cacheable=true)
    public static List<AccountResult> search(String searchTerm) {
        return AccountService.search(searchTerm);
    }
}

This is Module 7's Account search capstone, now properly layered — AccountController is a thin, one-line pass-through; AccountService holds the real logic; AccountSelector owns the query. Each piece could be tested (Module 14) and changed independently.

Exercise

Which layer should contain the logic for deciding which Accounts count as "high value" based on their annual revenue — Controller, Service, or Selector? Explain, as a comment.

Show hint

Think about which layer owns actual business rules.

APEX

Exercise

Challenge: explain, as a comment, why AccountService having no @AuraEnabled annotations at all is a deliberate design choice, not an oversight.

Show hint

Think about what @AuraEnabled's presence would imply about this class's purpose.

APEX

Controller/Service/Selector Patterns for LWC Quiz

1. What should the Controller layer contain?

2. What is the Selector layer responsible for?

3. Why does the Service layer typically have no @AuraEnabled annotations?

4. What is a genuine benefit of this three-layer split?

5. Could the same Service/Selector logic be reused by both an LWC Controller and a scheduled batch job?

Log in to submit the quiz and save your score.

My Notes

Log in to keep private notes on this lesson.

Questions about this lesson

No questions yet — be the first to ask.

Log in to ask a question about this lesson.

Summary

Splitting Apex into Controller, Service, and Selector layers applies Lesson 1's separation-of-concerns principle to the server side — each layer independently testable and reusable beyond just the one LWC component that first needed it.