Skip to main content
Archived design reference — not the current architecture

These docs describe a proposed Simplified Hexagonal Architecture from an internal design exploration that was not adopted. The design principles (entity invariants, contracts, dependency inversion, load → authorize → validate → execute, in-memory-fake testing) remain useful, but the specifics below do not exist in this codebase: the package paths packages/core / packages/infra / packages/shared, the @packages/* import aliases, the main.ts wiring entry point, and the EnableArchitectureTransition feature flag. Do not follow the paths, aliases, or imports here as-is.

Contracts (Interfaces)

← Back to README


What Is a Contract?

A contract is an interface - a promise of "what" without "how".

Think of it like a job description:

// This is a contract
interface Mailer {
send(to: string, subject: string, body: string): Promise<void>;
}

It says:

  • "I need something that can send emails"
  • "I don't care HOW it sends them"

Why Use Contracts?

The contract doesn't know if you're using:

  • SendGrid
  • Mailgun
  • AWS SES
  • A fake that logs to console
  • A test spy that records calls

This gives you:

  • Testability - Swap real implementations for fakes
  • Flexibility - Change providers without changing core
  • Decoupling - Core doesn't depend on external systems

Where Contracts Live

Rule: The contract lives with whoever NEEDS it, not whoever IMPLEMENTS it.

packages/
├── core/
│ └── src/
│ ├── orders/
│ │ ├── OrderRepository.ts # Only orders needs this
│ │ └── ...
│ │
│ └── shared/
│ └── Mailer.ts # Multiple features need this

└── infra/
└── src/
├── orders/
│ └── OrderRepositoryMongo.ts # Implements OrderRepository
└── shared/
└── email/
└── SendGridMailer.ts # Implements Mailer

Example: Repository Contract

A repository contract defines how to persist and retrieve domain objects:

// packages/core/src/orders/OrderRepository.ts
import { Order } from './Order';

export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
}

Adding Read Operations (CQRS)

When you need both write operations (entities) and read operations (DTOs), extend your repository:

// packages/core/src/orders/OrderRepository.ts
import { Order } from './Order';
import { OrderDetails, OrderSummary } from './OrderReadModels';

export interface OrderRepository {
// Writes (entities)
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;

// Reads (DTOs)
getDetails(orderId: string): Promise<OrderDetails | null>;
listByCustomer(customerId: string): Promise<OrderSummary[]>;
}

See CQRS and Read Models for details on when to use this pattern.


Example: Mailer Contract

// packages/core/src/shared/Mailer.ts
export interface Mailer {
send(to: string, subject: string, body: string): Promise<void>;
}

Example: Payment Gateway Contract

// packages/core/src/shared/PaymentGateway.ts
export interface PaymentResult {
success: boolean;
transactionId: string;
error?: string;
}

export interface PaymentGateway {
charge(customerId: string, amount: number): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<PaymentResult>;
}

Example: Event Bus Contract

// packages/core/src/shared/EventBus.ts
export interface Event {
type: string;
payload: unknown;
timestamp: Date;
}

export interface EventBus {
publish(event: Event): Promise<void>;
subscribe(type: string, handler: (event: Event) => Promise<void>): void;
}

Example: Logger Contract

For detailed logging patterns and implementation, see Logging.

// packages/core/src/shared/Logger.ts
export interface LogData {
requestId: string;
event: string;
[key: string]: unknown;
}

export interface Logger {
info(data: LogData): void;
warn(data: LogData): void;
error(data: LogData): void;
}

Contract Design Guidelines

1. Keep It Minimal

Only define what you actually need:

// ❌ Too broad - you probably don't need all this
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
findByStatus(status: string): Promise<Order[]>;
findByDateRange(start: Date, end: Date): Promise<Order[]>;
findAll(): Promise<Order[]>;
count(): Promise<number>;
delete(id: string): Promise<void>;
// ... 20 more methods
}

// ✅ Start minimal, add as needed
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}

2. Use Domain Types

Return domain objects, not raw data:

// ❌ Returns raw data
interface OrderRepository {
findById(id: string): Promise<{
id: string;
customer_id: string;
items: string;
status: string;
} | null>;
}

// ✅ Returns domain object
interface OrderRepository {
findById(id: string): Promise<Order | null>;
}

3. Be Explicit About Async

Database and network operations are async:

// ✅ Explicit Promise return types
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}

4. Handle Not Found

Use null for "not found" instead of throwing:

interface OrderRepository {
// Returns null if not found, action decides what to do
findById(id: string): Promise<Order | null>;
}

When to Use core/shared/

Decision Guide

Ask these questions in order:

QuestionYes →No →
Is it used by 2+ features?core/shared/Keep in feature
Is it a cross-cutting concern? (auth, logging, errors, transactions)core/shared/Keep in feature
Does every action need it? (e.g., AuthContext)core/shared/Keep in feature
Is it infrastructure-agnostic and reusable?core/shared/Keep in feature

Rule: The contract lives with whoever NEEDS it, not whoever IMPLEMENTS it.


What Belongs in core/shared/

CategoryExamplesWhy Shared
AuthorizationAuthContext.tsEvery user action needs auth context
Cross-cutting servicesMailer.ts, Logger.tsMultiple features send emails/log
Error typeserrors.ts (NotFoundError, BusinessError, InvariantError)Consistent error handling across features
Transaction handlingTransactionManager.tsAny feature may need atomic operations
Common patternsResult.tsStandardized return types across actions

What Does NOT Belong in core/shared/

Keep In FeatureReason
Feature-specific contractsOrderRepository is only used by orders feature
Feature-specific typesOrderStatus enum belongs with Order entity
Feature policiesOrderPolicies only authorizes order operations
Domain value objectsMoney, Address belong with the feature that owns them

Migration Rule

Start in feature, promote to shared when needed.

Don't preemptively put contracts in core/shared/. When a second feature needs the same contract:

  1. Move the contract to core/shared/
  2. Move the implementation to infra/shared/{technology}/
  3. Update imports in both features

Example: Promoting a Contract

Before: Only orders feature sends emails
→ Mailer contract in core/orders/Mailer.ts
→ SendGridMailer in infra/orders/SendGridMailer.ts

After: Customers feature also needs to send emails
→ Move Mailer to core/shared/Mailer.ts
→ Move SendGridMailer to infra/shared/email/SendGridMailer.ts
→ Update imports in both features

Quick Reference

SituationLocation
Only orders/ needs OrderRepositorycore/orders/OrderRepository.ts
Both orders/ and users/ need Mailercore/shared/Mailer.ts
Every action needs AuthContextcore/shared/authorization/AuthContext.ts
All features use same error typescore/shared/errors.ts

Shared Contract Example

// packages/core/src/shared/Mailer.ts
export interface Mailer {
send(to: string, subject: string, body: string): Promise<void>;
}

Used by multiple features:

// packages/core/src/orders/actions/createOrder.ts
import { Mailer } from '../../shared/Mailer';

// packages/core/src/users/actions/registerUser.ts
import { Mailer } from '../../shared/Mailer';

Guidelines

  1. Start in feature, move when needed - Don't share prematurely
  2. Keep shared contracts simple - Complex contracts may need a dedicated feature
  3. Avoid vague names - Use shared/, not utils/ or common/

When to Promote Shared to a Feature

If a shared contract grows too complex, consider promoting it to a full feature:

SignalAction
Many email templates neededCreate notifications/ feature
Complex payment flowsCreate payments/ feature
Multiple providers with business logicConsider dedicated feature
Contract has its own entitiesDefinitely needs a feature

Next Steps