Hexagonal Architecture (Ports and Adapters)
Isolates application core from external concerns—core defines interfaces (ports), external systems provide implementations (adapters). Enables easy implementation swapping, testability via mocks, and technology-agnostic core.
Core Patterns
Demonstrates the full port/adapter pattern from application core through secondary ports, secondary adapters, primary adapters, and composition root with TypeScript examples including adapter swapping and both unit and integration testing strategies.
- Secondary ports (interfaces) defined by the application core for repository and email service contracts
- Secondary adapters implementing ports: Postgres/Mongo repositories, SendGrid/AWS email services
- Primary adapters as entry points: HTTP controllers and CLI adapters wired at the composition root
Core Concepts
Port (Interface)
Interface defined by the application core describing what it needs from the outside world.
Two types:
- Primary (Driving) Ports: API the application exposes (e.g., use cases)
- Secondary (Driven) Ports: Dependencies the application needs (e.g., repository, email service)
Adapter (Implementation)
Concrete implementation of a port. Translates between external system and application core.
Two types:
- Primary (Driving) Adapters: Trigger application (e.g., HTTP controller, CLI, GraphQL resolver)
- Secondary (Driven) Adapters: Provide services to application (e.g., Postgres repository, SendGrid email)
Pattern Structure
1. Application Core (Business Logic)
// core/domain/User.ts
export class User {
constructor(
public readonly id: string,
public readonly email: string,
public readonly name: string,
) {}
}
// core/use-cases/RegisterUser.ts
export class RegisterUserUseCase {
constructor(
private userRepository: IUserRepository, // Secondary port
private emailService: IEmailService, // Secondary port
) {}
async execute(email: string, name: string): Promise<Result<User>> {
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
return Result.fail("Email already registered");
}
const user = new User(generateId(), email, name);
await this.userRepository.save(user);
await this.emailService.sendWelcome(email, name);
return Result.ok(user);
}
}
2. Secondary Ports (Interfaces)
// core/ports/IUserRepository.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// core/ports/IEmailService.ts
export interface IEmailService {
sendWelcome(email: string, name: string): Promise<void>;
}
3. Secondary Adapters (Implementations)
// adapters/repositories/PostgresUserRepository.ts
export class PostgresUserRepository implements IUserRepository {
constructor(private db: PrismaClient) {}
async findById(id: string): Promise<User | null> {
const row = await this.db.user.findUnique({ where: { id } });
return row ? new User(row.id, row.email, row.name) : null;
}
async findByEmail(email: string): Promise<User | null> {
const row = await this.db.user.findUnique({ where: { email } });
return row ? new User(row.id, row.email, row.name) : null;
}
async save(user: User): Promise<void> {
await this.db.user.upsert({
where: { id: user.id },
create: { id: user.id, email: user.email, name: user.name },
update: { email: user.email, name: user.name },
});
}
}
// adapters/services/SendGridEmailService.ts
export class SendGridEmailService implements IEmailService {
constructor(private client: SendGridClient) {}
async sendWelcome(email: string, name: string): Promise<void> {
await this.client.send({
to: email,
from: "noreply@example.com",
subject: "Welcome!",
text: `Hi ${name}, welcome to our app!`,
});
}
}
// adapters/services/ConsoleEmailService.ts (Mock for testing)
export class ConsoleEmailService implements IEmailService {
async sendWelcome(email: string, name: string): Promise<void> {
console.log(`[EMAIL] Welcome ${name} at ${email}`);
}
}
4. Primary Adapters (Entry Points)
// adapters/http/UserController.ts (HTTP adapter)
export class UserController {
constructor(private registerUser: RegisterUserUseCase) {}
async register(req: Request, res: Response): Promise<void> {
const { email, name } = req.body;
const result = await this.registerUser.execute(email, name);
if (result.isSuccess) {
res.status(201).json(result.value);
} else {
res.status(400).json({ error: result.error });
}
}
}
// adapters/cli/UserCLI.ts (CLI adapter)
export class UserCLI {
constructor(private registerUser: RegisterUserUseCase) {}
async register(email: string, name: string): Promise<void> {
const result = await this.registerUser.execute(email, name);
if (result.isSuccess) {
console.log(`User registered: ${result.value.id}`);
} else {
console.error(`Error: ${result.error}`);
}
}
}
5. Composition (Wiring)
// main.ts (Composition root)
const db = new PrismaClient();
const sendgrid = new SendGridClient(process.env.SENDGRID_KEY);
const userRepository = new PostgresUserRepository(db);
const emailService = new SendGridEmailService(sendgrid);
const registerUserUseCase = new RegisterUserUseCase(
userRepository,
emailService,
);
const userController = new UserController(registerUserUseCase);
app.post("/users/register", (req, res) => userController.register(req, res));
Testing with Adapters
Unit Testing (Mock Adapters)
// __tests__/RegisterUser.test.ts
describe("RegisterUserUseCase", () => {
it("should register new user", async () => {
const mockRepo: IUserRepository = {
findById: jest.fn(),
findByEmail: jest.fn().mockResolvedValue(null),
save: jest.fn(),
};
const mockEmail: IEmailService = {
sendWelcome: jest.fn(),
};
const useCase = new RegisterUserUseCase(mockRepo, mockEmail);
const result = await useCase.execute("test@example.com", "John");
expect(result.isSuccess).toBe(true);
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ email: "test@example.com" }),
);
expect(mockEmail.sendWelcome).toHaveBeenCalledWith(
"test@example.com",
"John",
);
});
it("should fail if email exists", async () => {
const existingUser = new User("1", "test@example.com", "Existing");
const mockRepo: IUserRepository = {
findById: jest.fn(),
findByEmail: jest.fn().mockResolvedValue(existingUser),
save: jest.fn(),
};
const mockEmail: IEmailService = {
sendWelcome: jest.fn(),
};
const useCase = new RegisterUserUseCase(mockRepo, mockEmail);
const result = await useCase.execute("test@example.com", "John");
expect(result.isSuccess).toBe(false);
expect(result.error).toBe("Email already registered");
expect(mockRepo.save).not.toHaveBeenCalled();
});
});
Integration Testing (Real Adapters)
// __tests__/integration/UserRegistration.test.ts
describe("User Registration (Integration)", () => {
let db: PrismaClient;
let userRepository: PostgresUserRepository;
let emailService: ConsoleEmailService;
let useCase: RegisterUserUseCase;
beforeAll(() => {
db = new PrismaClient();
userRepository = new PostgresUserRepository(db);
emailService = new ConsoleEmailService();
useCase = new RegisterUserUseCase(userRepository, emailService);
});
it("should register user in database", async () => {
const result = await useCase.execute("integration@test.com", "Integration");
expect(result.isSuccess).toBe(true);
const savedUser = await db.user.findUnique({
where: { email: "integration@test.com" },
});
expect(savedUser).toBeTruthy();
expect(savedUser?.name).toBe("Integration");
});
});
Swapping Adapters
Example: Switch from Postgres to MongoDB
// adapters/repositories/MongoUserRepository.ts
export class MongoUserRepository implements IUserRepository {
constructor(private mongo: MongoClient) {}
async findById(id: string): Promise<User | null> {
const doc = await this.mongo.db().collection("users").findOne({ _id: id });
return doc ? new User(doc._id, doc.email, doc.name) : null;
}
async findByEmail(email: string): Promise<User | null> {
const doc = await this.mongo.db().collection("users").findOne({ email });
return doc ? new User(doc._id, doc.email, doc.name) : null;
}
async save(user: User): Promise<void> {
await this.mongo
.db()
.collection("users")
.updateOne(
{ _id: user.id },
{ $set: { email: user.email, name: user.name } },
{ upsert: true },
);
}
}
// main.ts - Change ONE line
const userRepository = new MongoUserRepository(mongo); // Was: PostgresUserRepository
// Everything else stays the same!
Example: Switch from SendGrid to AWS SES
// adapters/services/AWSEmailService.ts
export class AWSEmailService implements IEmailService {
constructor(private ses: SESClient) {}
async sendWelcome(email: string, name: string): Promise<void> {
await this.ses.send(
new SendEmailCommand({
Source: "noreply@example.com",
Destination: { ToAddresses: [email] },
Message: {
Subject: { Data: "Welcome!" },
Body: { Text: { Data: `Hi ${name}, welcome!` } },
},
}),
);
}
}
// main.ts - Change ONE line
const emailService = new AWSEmailService(ses); // Was: SendGridEmailService
Frontend Example (React)
// core/ports/IAnalytics.ts (Secondary port)
export interface IAnalytics {
track(event: string, properties?: Record<string, any>): void;
identify(userId: string, traits?: Record<string, any>): void;
}
// adapters/analytics/SegmentAnalytics.ts
export class SegmentAnalytics implements IAnalytics {
track(event: string, properties?: Record<string, any>): void {
analytics.track(event, properties);
}
identify(userId: string, traits?: Record<string, any>): void {
analytics.identify(userId, traits);
}
}
// adapters/analytics/GoogleAnalytics.ts
export class GoogleAnalytics implements IAnalytics {
track(event: string, properties?: Record<string, any>): void {
gtag('event', event, properties);
}
identify(userId: string, traits?: Record<string, any>): void {
gtag('set', { user_id: userId, ...traits });
}
}
// React Context (Primary adapter)
const AnalyticsContext = createContext<IAnalytics | null>(null);
export const AnalyticsProvider = ({ children, analytics }: Props) => (
<AnalyticsContext.Provider value={analytics}>
{children}
</AnalyticsContext.Provider>
);
export const useAnalytics = () => {
const analytics = useContext(AnalyticsContext);
if (!analytics) throw new Error('Missing AnalyticsProvider');
return analytics;
};
// Component uses port (doesn't know which adapter)
const PurchaseButton = () => {
const analytics = useAnalytics();
const handleClick = () => {
analytics.track('purchase_button_clicked', { price: 99 });
// ...
};
return <button onClick={handleClick}>Purchase</button>;
};
// App.tsx - Swap adapter easily
const analytics = new SegmentAnalytics(); // or GoogleAnalytics
<AnalyticsProvider analytics={analytics}>
<PurchaseButton />
</AnalyticsProvider>
Folder Structure
src/
├── core/
│ ├── domain/
│ │ └── User.ts
│ ├── use-cases/
│ │ └── RegisterUser.ts
│ └── ports/
│ ├── IUserRepository.ts
│ └── IEmailService.ts
├── adapters/
│ ├── repositories/
│ │ ├── PostgresUserRepository.ts
│ │ └── MongoUserRepository.ts
│ ├── services/
│ │ ├── SendGridEmailService.ts
│ │ └── AWSEmailService.ts
│ └── http/
│ └── UserController.ts
└── main.ts (composition root)
Benefits
- Testability: Mock adapters for unit tests
- Flexibility: Swap implementations without touching core
- Technology independence: Core doesn’t know about DB/framework
- Parallel development: Teams can work on adapters independently
- Legacy integration: Create adapter for legacy system
When to Use
- Need to swap implementations (Postgres → MongoDB, SendGrid → SES)
- Want highly testable code
- Building for multiple platforms (web + mobile + CLI)
- Integrating with legacy systems
- Long-term maintainability priority
When NOT to Use
- Simple CRUD apps
- Prototypes/MVPs
- Single implementation guaranteed
- Team unfamiliar with pattern
References
- Main SKILL
- Clean Architecture
- SOLID Principles - DIP enables ports/adapters
- Backend Integration
- Frontend Integration
External: