ADR-009: Service Architecture Conventions
Status
Accepted
Context
An audit of the 12 domain skills revealed four distinct service architecture patterns in use across the codebase:
- Plain object — module-level const with methods. Used by newer domains: locations, resources, reservations, waitlist, migrations, most credit services.
- Class (instantiated) — holds external SDK client state. Used by EmailService (Resend), SmsService (Twilio), StripeService (Stripe), ConsentService, CreditConsumptionService, CreditTransferService.
- Class (static) — all methods are static, no instance state. Used by UserService, AuthUserService, FulfillmentService, StripeWebhookService, SecurityService, CreditSubscriptionService.
- Standalone functions — bare module exports. Used by user-sync, ReservationPaymentService, prepaidExpiration, GeocodingService, StaticMapSigningService.
Additionally, some services have unlabeled types (AccessGateService, CodeGenerationService, LockStatusService), and ReservationCreationService mixes plain object exports with standalone function exports in the same module.
This matters more than usual because AI agents are the primary code authors. Agents pattern-match on existing code — if they see a class in EmailService, they'll write a class for the next notification channel. Multiple patterns means agents produce inconsistent output depending on which file they happen to read first.
Decision
Plain objects as the single service pattern
All services use the plain object pattern. No exceptions.
export const LocationService = {
async create(data: CreateInput, tx: TransactionClient) { ... },
async list(filters: ListFilters, tx: TransactionClient) { ... },
}
Services that wrap external SDK clients (EmailService, SmsService, StripeService) use a module-scoped client instance with a plain object:
const client = new Resend(process.env.RESEND_API_KEY)
export const EmailService = {
async sendPasswordReset(params: PasswordResetParams) {
await client.emails.send(...)
},
}
Standalone utility functions (1-2 related functions that don't warrant grouping) remain as bare exports. The threshold: if there are 3+ related functions, group them in a plain object.
Class (static) elimination
Class (static) is a plain object with extra syntax. These services hold no instance state and gain nothing from being classes. Migrate them to plain objects: UserService, AuthUserService, FulfillmentService, StripeWebhookService, SecurityService, CreditSubscriptionService.
Instantiated class migration
Instantiated classes that hold external SDK clients are technically valid but create a pattern agents replicate indiscriminately. Migrate to plain objects with module-scoped clients: EmailService, SmsService, StripeService, ConsentService, CreditConsumptionService, CreditTransferService.
Mixed export cleanup
ReservationCreationService exports both a plain object and standalone functions from the same module. Consolidate into a single plain object export.
Alternatives considered
Keep instantiated classes for SDK wrappers. The class pattern is technically valid for services holding client state. Rejected because agents replicate patterns indiscriminately — a single convention eliminates ambiguity regardless of which file an agent reads first.
NestJS-style dependency injection. Would provide a principled reason for classes (constructor injection, testability). Rejected because the project doesn't use an IoC container, Prisma transactions are passed as arguments, and adopting DI would be a framework-level change disproportionate to the problem.
Functions-only (no plain objects). Maximum simplicity, best tree-shaking. Rejected because plain objects provide discoverability via autocomplete (LocationService. shows all methods) and consistent naming across imports.
Consequences
Benefits:
- Agents always produce the same service style regardless of which domain they pattern-match from
- Eliminates the class (static) anti-pattern that adds syntax without value
- One pattern to learn, one pattern to review
Tradeoffs:
- Refactoring 6 class (static) services and 6 instantiated class services is a non-trivial diff across many files. Each migration changes every import site.
- Module-scoped SDK clients lose constructor injection — tests must mock at the module level instead. This is already how tests work in this codebase, so the practical impact is nil.
Risks:
- Migration must be done carefully per-service to avoid breaking imports. Each service migration should be its own commit with passing tests.
- Some services may have subtle
this-dependent behavior in their class form that doesn't surface until runtime. Test coverage mitigates this — run domain tests after each migration.