Hey everyone, Alex here. Welcome back to another edition of Coding with Alex on sysseder.com.
Just yesterday, a post titled "Software Architecture Guide" popped up on the front page of Hacker News. It was a nostalgic but highly relevant look back at architectural paradigms from around 2019. It got me thinking: in our rush to adopt serverless, Kubernetes, AI-driven microservices, and edge computing, have we forgotten the foundational blueprints of software architecture? Or worse, are we reinventing the wheel and calling it a "new paradigm"?
Today, we are going to strip away the buzzwords. We are going to look at three classic software architecture patterns—CQRS/Event Sourcing, the Outbox Pattern, and Hexagonal (Ports and Adapters) Architecture. We'll explore why they were crucial five years ago, why they are even more critical today in our cloud-native world, and how you can implement them to build resilient, maintainable systems.
The Architectural Debt of "Move Fast and Break Things"
We've all been there. You start a new project with a simple MVC (Model-View-Controller) framework. It's fast, it's intuitive, and you ship your MVP in record time. But fast forward two years: your database is locked up with long-running read queries, your write operations are failing because of distributed transaction issues, and your business logic is hopelessly tangled up with your database driver code.
This is architectural debt. As systems scale, the "one-database-to-rule-them-all" approach falls apart. To survive, we have to decouple. Let’s dive into how we do that cleanly.
Pattern 1: CQRS and Event Sourcing (Decoupling Reads from Writes)
Command Query Responsibility Segregation (CQRS) states that a method (or service) should either be a Command (performing an action/mutation) or a Query (returning data), but never both. When we scale this to system architecture, it means we use different data models—and often different databases—for writing data and reading data.
Why it matters now
In modern web apps, read traffic often outnumbers write traffic by orders of magnitude (think of browsing an e-commerce site versus actually placing an order). If your read queries are hitting the same relational database that is trying to handle high-throughput writes, you will hit a bottleneck.
The Architecture in Action
By pairing CQRS with Event Sourcing, instead of storing the current state of an object, we store a sequence of state-changing events.
[Client]
│
├── (Command: SubmitOrder) ──> [Write Service] ──> [Append Event] ──> [Event Store (Postgres/Kafka)]
│ │
│ (Async Projection)
│ ▼
└── (Query: GetOrderHistory) ─> [Read Service] <── [Read DB (Redis/Elasticsearch)]
Implementing a Simple Event Store in Node.js
Let's look at how we might capture state changes as events rather than direct updates to a database table:
// A simple implementation of an Event-Sourced Order Entity
class Order {
constructor() {
this.orderId = null;
this.status = 'PENDING';
this.items = [];
this.total = 0;
this.changes = []; // Track uncommitted events
}
// Command Handler
createOrder(orderId, items, total) {
this.applyChange({
type: 'ORDER_CREATED',
payload: { orderId, items, total, timestamp: Date.now() }
});
}
payOrder() {
if (this.status !== 'PENDING') throw new Error("Order cannot be paid.");
this.applyChange({
type: 'ORDER_PAID',
payload: { orderId: this.orderId, timestamp: Date.now() }
});
}
// Apply state changes
applyChange(event, isNew = true) {
this.playback(event);
if (isNew) this.changes.push(event);
}
// The Rehydration Logic (State Projection)
playback(event) {
switch (event.type) {
case 'ORDER_CREATED':
this.orderId = event.payload.orderId;
this.items = event.payload.items;
this.total = event.payload.total;
this.status = 'CREATED';
break;
case 'ORDER_PAID':
this.status = 'PAID';
break;
}
}
}
To reconstruct the current state of an order, you simply load all events associated with that orderId from your database and run them through the playback() method. This gives you a perfect, immutable audit log of your system's history.
Pattern 2: The Transactional Outbox Pattern (Guaranteed Event Delivery)
If you've adopted microservices, you know the pain of distributed transactions. Let's say a user registers. You need to save the user to your database AND publish a UserRegistered event to an external message broker like Apache Kafka or RabbitMQ.
What happens if your database write succeeds, but the network hiccups and your message broker is unreachable? Your system is now in an inconsistent state. The user exists, but downstream services (like the email notifier) have no idea.
The Solution: The Outbox Pattern
The Transactional Outbox Pattern solves this by making the database write and the event publication part of the same local database transaction. Instead of sending the event directly to the broker, you write it to an outbox table in your database. A separate background process (a Relayer) polls this table and publishes the messages asynchronously.
Database Schema Example
Here is what your SQL schema might look like to support this pattern:
-- The primary business entity table
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- The transactional outbox table
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(50) DEFAULT 'PENDING', -- PENDING, PROCESSED, FAILED
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
When you register a user, you execute both inserts in a single transaction:
BEGIN TRANSACTION;
INSERT INTO users (id, email)
VALUES ('d3b07384-d113-4956-a534-1123456789ab', 'alex@sysseder.com');
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
'e5c18495-e224-5067-b645-2234567890bc',
'User',
'd3b07384-d113-4956-a534-1123456789ab',
'USER_REGISTERED',
'{"email": "alex@sysseder.com", "userId": "d3b07384-d113-4956-a534-1123456789ab"}'
);
COMMIT;
Now, your database guarantees that either both records are written, or neither is. An external consumer (using a tool like Debezium or a simple cron job) can tail the outbox_events table and reliably push those events to Kafka.
Pattern 3: Hexagonal Architecture (Protecting Your Domain)
Also known as "Ports and Adapters", Hexagonal Architecture aims to isolate your core business logic from external concerns like databases, web frameworks, email services, and UI components.
Think about how often you've wanted to switch from MongoDB to PostgreSQL, or swap out Express.js for Fastify, only to realize that database queries or framework-specific request objects are scattered across your entire codebase. Hexagonal Architecture prevents this coupling by defining interfaces (Ports) and implementations (Adapters).
Anatomy of a Hexagon
- Domain Core: The pure business rules. No imports from external libraries or databases.
- Ports: Interfaces that define how data goes in and out of the core (e.g., a
UserRepositoryinterface). - Adapters: The actual implementations of those interfaces (e.g., a
PostgresUserRepositoryor aRESTExpressController).
A TypeScript Implementation of Ports and Adapters
Let's define a clean application of this pattern using TypeScript:
// 1. PORT (Interface) - Must be stored in the Domain boundary
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// 2. DOMAIN ENTITY (Core Business Logic)
class User {
constructor(public readonly id: string, public email: string) {}
changeEmail(newEmail: string) {
if (!newEmail.includes('@')) {
throw new Error("Invalid email format");
}
this.email = newEmail;
}
}
// 3. ADAPTER (External Dependency Implementation)
class PostgresUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
// Imagine actual DB queries running here via Knex/Prisma/pg
console.log(`Querying Postgres for user: ${id}`);
return new User(id, "alex@sysseder.com");
}
async save(user: User): Promise<void> {
console.log(`Saving user ${user.id} to Postgres`);
}
}
// 4. USE CASE (orchestrator using the Port, not the Adapter)
class ChangeEmailUseCase {
constructor(private userRepository: UserRepository) {}
async execute(userId: string, newEmail: string): Promise<void> {
const user = await this.userRepository.findById(userId);
if (!user) throw new Error("User not found");
user.changeEmail(newEmail);
await this.userRepository.save(user);
}
}
// How we wire it all together (Dependency Injection)
const dbAdapter = new PostgresUserRepository();
const emailService = new ChangeEmailUseCase(dbAdapter);
Because the ChangeEmailUseCase depends entirely on the UserRepository interface (the Port) rather than the database driver (the Adapter), we can easily mock the database in our unit tests or swap out Postgres for a completely different persistence layer without touching a single line of business logic.
Conclusion: The Classics Are Classics for a Reason
When you read a modern "Software Architecture Guide", it is easy to get overwhelmed by the sheer variety of cloud services, container orchestrators, and fancy frameworks. But remember: the core challenges of software engineering haven't changed. We still need to decouple write systems from read systems, handle eventual consistency gracefully, and protect our business logic from external churn.
By understanding and selectively applying patterns like CQRS, the Transactional Outbox, and Hexagonal Architecture, you can write code that scales gracefully and remains a joy to work in—years down the line.
What about you? What classic architectural patterns do you find yourself reaching for most often in your day-to-day work? Have you ever had to migrate away from a system because these boundaries weren't set up early on? Let me know in the comments below, or hit me up on Twitter!
Until next time, happy coding!