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.

Infrastructure

← Back to README


What Is Infrastructure?

Infrastructure contains implementations of contracts. These are the actual external systems:

  • Databases (MongoDB, PostgreSQL, etc.)
  • Email providers (SendGrid, Mailgun, etc.)
  • Payment gateways (Stripe, PayPal, etc.)
  • File storage (S3, local filesystem, etc.)
  • Message queues (RabbitMQ, SQS, etc.)

Folder Structure

Infrastructure uses a hybrid approach: feature-specific implementations organized by feature, with shared utilities in a shared/ folder.

packages/
└── infra/
└── src/
├── shared/ # Cross-cutting infrastructure
│ ├── mongodb/
│ │ ├── connection.ts # Connection management
│ │ └── BaseMongoRepository.ts
│ ├── email/
│ │ └── SendGridMailer.ts # Implements core/shared/Mailer.ts
│ ├── payments/
│ │ └── StripePaymentGateway.ts # Implements core/shared/PaymentGateway.ts
│ └── logging/
│ └── PinoLogger.ts # Implements core/shared/Logger.ts

├── orders/ # Feature-specific infra
│ ├── OrderRepositoryMongo.ts # Implements core/orders/OrderRepository.ts
│ └── memory/
│ └── InMemoryOrderRepository.ts

└── users/ # Feature-specific infra
├── UserRepositoryMongo.ts # Implements core/users/UserRepository.ts
└── memory/
└── InMemoryUserRepository.ts

Where Does It Go? Follow the Contract

The single rule: Implementation location mirrors contract location.

Contract Location → Implementation Location
─────────────────────────────────────────────────────────────
core/shared/Mailer.ts → infra/shared/email/SendGridMailer.ts
core/shared/Logger.ts → infra/shared/logging/PinoLogger.ts
core/orders/OrderRepository.ts → infra/orders/OrderRepositoryMongo.ts
core/users/UserRepository.ts → infra/users/UserRepositoryMongo.ts

Decision Guide

QuestionAnswerLocation
Is the contract in core/shared/?Yesinfra/shared/{technology}/
Is the contract in core/{feature}/?Yesinfra/{feature}/
Is it infrastructure plumbing (connection pools, base classes)?Yesinfra/shared/{technology}/
Is it a test fake for a feature?Yesinfra/{feature}/memory/

Why This Works

  1. Zero decisions - Contract location already decided where implementation goes
  2. Mirrors core - @infra/orders@core/orders
  3. Clear ownership - Feature team owns everything in infra/{feature}/
  4. Easy cleanup - Delete a feature? Delete core/{feature}/ and infra/{feature}/

What Goes in shared/

Only these belong in infra/shared/:

CategoryExamplesWhy Shared
Shared contract implementationsSendGridMailer, PinoLogger, StripePaymentGatewayContract is in core/shared/
Connection managementMongoDB connection, Redis clientMultiple features use same connection
Base classesBaseMongoRepositoryDRY for common patterns
Infrastructure utilitiesRetry helpers, circuit breakersCross-cutting concerns

What Does NOT Go in shared/

Don't Put HerePut Here InsteadWhy
OrderRepositoryMongoinfra/orders/Contract is in core/orders/
Feature-specific adaptersinfra/{feature}/Only one feature uses it
Test fakes for featuresinfra/{feature}/memory/Co-locate with feature

Example: Feature Repository (MongoDB + Mongoose)

// packages/infra/src/orders/OrderRepositoryMongo.ts
import { Model } from 'mongoose';
import { Order, OrderItem, OrderStatus } from '@packages/core/orders/Order';
import { OrderRepository } from '@packages/core/orders/OrderRepository';
import { OrderModel, IOrder } from './models/OrderModel';

export class OrderRepositoryMongo implements OrderRepository {
constructor(private model: Model<IOrder> = OrderModel) {}

async save(order: Order): Promise<void> {
await this.model.findOneAndUpdate(
{ _id: order.id },
{
customerId: order.customerId,
items: order.items,
status: order.status,
cancelReason: order.cancelReason,
},
{ upsert: true, new: true }
);
}

async findById(id: string): Promise<Order | null> {
const doc = await this.model.findById(id).lean();
if (!doc) return null;
return this.toDomain(doc);
}

async findByCustomer(customerId: string): Promise<Order[]> {
const docs = await this.model
.find({ customerId })
.sort({ createdAt: -1 })
.lean();
return docs.map(doc => this.toDomain(doc));
}

private toDomain(doc: IOrder): Order {
return new Order(
doc._id.toString(),
doc.customerId,
doc.items as OrderItem[],
doc.status as OrderStatus,
doc.cancelReason
);
}
}

Mongoose Model Definition

// packages/infra/src/orders/models/OrderModel.ts
import mongoose, { Schema, Document } from 'mongoose';

export interface IOrder extends Document {
_id: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
price: number;
}>;
status: string;
cancelReason?: string;
createdAt: Date;
updatedAt: Date;
}

const OrderSchema = new Schema<IOrder>(
{
_id: { type: String, required: true },
customerId: { type: String, required: true, index: true },
items: [{
productId: { type: String, required: true },
quantity: { type: Number, required: true },
price: { type: Number, required: true },
}],
status: { type: String, required: true, index: true },
cancelReason: { type: String },
},
{ timestamps: true }
);

export const OrderModel = mongoose.model<IOrder>('Order', OrderSchema);

Note: For applications with complex read requirements, you may extend your repository with optimized DTO queries. See CQRS and Read Models for details.


Example: Shared Email Provider

// packages/infra/src/shared/email/SendGridMailer.ts
// Note: In shared/ because contract is in core/shared/Mailer.ts
import sgMail from '@sendgrid/mail';
import { Mailer } from '@packages/core/shared/Mailer';

export class SendGridMailer implements Mailer {
constructor(apiKey: string, private fromEmail: string) {
sgMail.setApiKey(apiKey);
}

async send(to: string, subject: string, body: string): Promise<void> {
await sgMail.send({
to,
from: this.fromEmail,
subject,
text: body
});
}
}

Example: Shared Payment Gateway

// packages/infra/src/shared/payments/StripePaymentGateway.ts
// Note: In shared/ because contract is in core/shared/PaymentGateway.ts
import Stripe from 'stripe';
import { PaymentGateway, PaymentResult } from '@packages/core/shared/PaymentGateway';

export class StripePaymentGateway implements PaymentGateway {
private stripe: Stripe;

constructor(apiKey: string) {
this.stripe = new Stripe(apiKey);
}

async charge(customerId: string, amount: number): Promise<PaymentResult> {
try {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Convert to cents
currency: 'usd',
customer: customerId,
confirm: true
});

return {
success: paymentIntent.status === 'succeeded',
transactionId: paymentIntent.id
};
} catch (error) {
return {
success: false,
transactionId: '',
error: error.message
};
}
}

async refund(transactionId: string, amount: number): Promise<PaymentResult> {
try {
const refund = await this.stripe.refunds.create({
payment_intent: transactionId,
amount: Math.round(amount * 100)
});

return {
success: refund.status === 'succeeded',
transactionId: refund.id
};
} catch (error) {
return {
success: false,
transactionId: '',
error: error.message
};
}
}
}

Example: Shared Logger

// packages/infra/src/shared/logging/PinoLogger.ts
// Note: In shared/ because contract is in core/shared/Logger.ts
import pino from 'pino';
import { Logger, LogData } from '@packages/core/shared/Logger';

export class PinoLogger implements Logger {
private logger: pino.Logger;

constructor(options?: pino.LoggerOptions) {
this.logger = pino({
level: process.env.LOG_LEVEL || 'info',
timestamp: pino.stdTimeFunctions.isoTime,
...options,
});
}

info(data: LogData): void {
this.logger.info(data);
}

warn(data: LogData): void {
this.logger.warn(data);
}

error(data: LogData): void {
this.logger.error(data);
}
}

Key Responsibilities

1. Implement the Contract

// Contract says:
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}

// Implementation fulfills it:
class OrderRepositoryMongo implements OrderRepository {
async save(order: Order): Promise<void> { /* ... */ }
async findById(id: string): Promise<Order | null> { /* ... */ }
}

2. Handle External System Details

  • Connection management
  • Query building
  • Error handling
  • Retries
  • Timeouts

3. Translate Between Formats

// Database document → Domain object
private toDomain(doc: IOrder): Order {
return new Order(
doc._id.toString(),
doc.customerId,
doc.items,
doc.status,
doc.cancelReason
);
}

// Domain object → Database document
async save(order: Order): Promise<void> {
await this.model.findOneAndUpdate(
{ _id: order.id },
{ customerId: order.customerId, items: order.items, status: order.status },
{ upsert: true }
);
}

Test Fakes (In-Memory Implementations)

Test fakes live in the feature's memory/ subfolder, co-located with the real implementation:

// packages/infra/src/orders/memory/InMemoryOrderRepository.ts
// Note: In orders/memory/ because it's a fake for orders/OrderRepositoryMongo.ts
import { Order } from '@packages/core/orders/Order';
import { OrderRepository } from '@packages/core/orders/OrderRepository';

export class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();

async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}

async findById(id: string): Promise<Order | null> {
return this.orders.get(id) || null;
}

async findByCustomer(customerId: string): Promise<Order[]> {
return Array.from(this.orders.values())
.filter(o => o.customerId === customerId);
}

// Test helpers
clear(): void {
this.orders.clear();
}

getAll(): Order[] {
return Array.from(this.orders.values());
}
}

Why Co-locate Fakes?

BenefitDescription
DiscoverabilityFind fake right next to real implementation
OwnershipFeature team maintains both real and fake
ConsistencyFake evolves with the contract
DeletionRemove feature, fakes go too

Import Rules

Infrastructure can import from:

  • @packages/core (to implement contracts and use domain objects)
  • @packages/infra/shared (for base classes and shared utilities)
  • ✅ External libraries (mongoose, @sendgrid/mail, etc.)
  • apps/ (infrastructure shouldn't know about specific apps or HTTP)
  • ❌ Other feature infra (e.g., @packages/infra/orders cannot import from @packages/infra/users)

Logging

Philosophy

PrincipleDescription
Log at boundariesEntry points (API, workers) log, not actions
Minimal loggingErrors and significant events only
Structured formatJSON objects, not string messages
Logger as dependencyPassed in deps, not imported globally

Logger Contract

// 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;
}

Implementation Example

// packages/infra/src/shared/logging/PinoLogger.ts
import pino from 'pino';
import { Logger, LogData } from '@packages/core/shared/Logger';

export class PinoLogger implements Logger {
private logger = pino({ level: process.env.LOG_LEVEL || 'info' });

info(data: LogData): void { this.logger.info(data); }
warn(data: LogData): void { this.logger.warn(data); }
error(data: LogData): void { this.logger.error(data); }
}

What NEVER to Log

CategoryExamples
SecretsAPI keys, tokens, passwords
PIIEmail, phone, name, address
FinancialCredit card numbers, bank accounts

Use identifiers (customerId) instead of the actual data.


Repository Pattern Enforcement

All data access in the infrastructure layer MUST go through Repository classes that implement core contracts. Standalone factory functions that return closures performing database queries are prohibited.

Correct:

export class AgentRepositoryMongo implements AgentRepository {
constructor(private readonly model: Model<any>) {}
async findByIds(ids: string[]): Promise<AgentReadData[]> { /* ... */ }
}

Incorrect:

export function createGetAgentsByIds(model: Model<any>) {
return async (ids: string[]) => { /* direct DB query */ };
}

Repository classes are easier to test, follow the contract defined in core, and keep infrastructure concerns properly encapsulated.


Next Steps