Skip to content

ADR-006: Service Layer Refinement

Service Layer Refinement

Services are reserved for complex business logic. Simple CRUD flows go directly from Notifier to Repository.

The original four-layer architecture (see ADR-001) positioned the service layer as mandatory for all data flows. In practice, this created unnecessary indirection:

  • Passthrough services: Many services simply forwarded calls from notifiers to repositories without adding logic
  • Artificial complexity: Simple list/detail features required a service class that added no value
  • Slower development: Every feature required creating a service even when not needed
  • Misleading architecture: The presence of a service implied business logic existed when it didn’t
  • When: After implementing 15+ features following the mandatory service layer rule
  • Where: All Flutter projects using MOFA architecture
  • Who: Flutter development team
  • Constraints:
    • Must maintain clear separation of concerns
    • Must have a clear rule for when services are required vs optional
    • Must not compromise testability
    • Must support both simple CRUD and complex orchestration workflows

The service layer is optional for simple CRUD flows and required for complex logic. The criteria for “complex” are explicitly defined.

A service is required when the logic involves any of these criteria:

  1. Multi-repository orchestration - Coordinating data from 2+ repositories in a single operation
  2. Cross-cutting concerns - Error handling orchestration, analytics tracking, permission checks that span features
  3. Complex business workflows - Multi-step operations with rollback, approval flows, state machines
  4. Data transformation beyond mapping - Computed fields, aggregations, business rule application
  5. Caching strategy coordination - Complex cache invalidation, TTL management, offline queue management
  6. External service integration - Third-party API calls, push notification orchestration, file upload coordination

A service is not needed when:

  1. Simple CRUD - Fetching a list, getting details, creating/updating/deleting a single entity
  2. Direct repository access - The notifier needs data from exactly one repository with no transformation
  3. UI-only logic - Pagination state, form validation, loading states (these belong in notifiers)

Simple CRUD flow (no service):

Notifier -> Repository -> Datasource

Complex flow (with service):

Notifier -> Service -> Multiple Repositories -> Multiple Datasources
// Simple list fetch - notifier accesses repository directly
@riverpod
class AttendeeListNotifier extends _$AttendeeListNotifier {
@override
Stream<List<AttendeeModel>> build() {
final repository = ref.watch(attendeeRepositoryProvider);
return repository.queryList(
filter: AttendeeFilterCriteria(eventId: eventId),
sort: AttendeeSortCriteria(name: SortOptions.ascending),
);
}
}
// Complex orchestration - service coordinates multiple repos
@riverpod
class BookingService extends _$BookingService {
Future<BookingResult> createBooking(BookingInput input) async {
final bookingRepo = ref.read(bookingRepositoryProvider);
final availabilityRepo = ref.read(availabilityRepositoryProvider);
final notificationService = ref.read(notificationServiceProvider);
// 1. Check availability across multiple resources
final isAvailable = await availabilityRepo.checkSlot(
input.resourceId, input.startTime, input.endTime);
if (!isAvailable) {
throw BookingConflictException(input.resourceId);
}
// 2. Create the booking
final booking = await bookingRepo.create(input);
// 3. Send confirmation notification
await notificationService.sendBookingConfirmation(booking);
// 4. Update availability cache
await availabilityRepo.invalidateSlot(input.resourceId);
return booking;
}
}
  1. Keep Service Layer Mandatory

    • Pros: Consistent architecture, every feature has the same shape
    • Cons: Passthrough services add noise, slower development for simple features
  2. Remove Service Layer Entirely

    • Pros: Simplest possible architecture
    • Cons: Complex orchestration logic would leak into notifiers
  3. Optional Service Layer with Clear Criteria (Chosen)

    • Pros: Right level of abstraction per feature, clear decision criteria
    • Cons: Developers must evaluate criteria (mitigated by explicit checklist)
TypeNamingExample
Feature business logic[Feature]ServiceBookingService
Cross-cutting orchestration[Concern]OrchestratorServiceDataMergeOrchestratorService
External integration[Integration]ServicePushNotificationService
  • Faster development for simple features (skip unnecessary service layer)
  • Clearer architecture - service presence signals actual business logic
  • Less code to maintain - no passthrough services
  • Better onboarding - new developers understand when to create services
  • Judgment required - developers must evaluate criteria (mitigated by the explicit checklist above)
  • Refactoring risk - a simple feature may need a service later (mitigated: adding a service later is straightforward)

When starting a new feature, use this checklist:

  • Does this feature need data from 2+ repositories? -> Service required
  • Does this feature have multi-step business logic? -> Service required
  • Does this feature need cross-cutting concern orchestration? -> Service required
  • Is this a simple CRUD operation on a single entity? -> No service needed
  • Is this a list/detail view with filtering and sorting? -> No service needed