Solution Architecture Knowledge

Solution Architecture Interview Guide
Table of Contents
- Clean Architecture
- Clean Code Principles
- Test-Driven Development (TDD)
- Domain-Driven Design (DDD)
- Microservices Patterns
- Distributed Transactions
- Saga Pattern
- CQRS & Event Sourcing
- Other Important Patterns
- Authentication & Authorization
Clean Architecture
Overview
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that separates concerns into layers, making systems independent of frameworks, UI, databases, and external agencies.
Core Principles
- Independence of Frameworks: Architecture doesn't depend on libraries
- Testability: Business rules can be tested without UI, database, or external elements
- Independence of UI: UI can change without changing the rest of the system
- Independence of Database: Business rules are not bound to the database
- Independence of External Agency: Business rules don't know anything about outside world
The Dependency Rule
Dependencies point inward: Source code dependencies must point only toward higher-level policies.
Layers (from outer to inner)
1. Frameworks & Drivers (Outermost)
- Web frameworks
- Database drivers
- UI components
- External interfaces
2. Interface Adapters
- Controllers
- Gateways
- Presenters
- View Models
3. Application Business Rules (Use Cases)
- Application-specific business rules
- Orchestrates data flow to/from entities
- Directs entities to use their enterprise-wide business rules
4. Enterprise Business Rules (Entities - Innermost)
- Enterprise-wide business rules
- High-level rules that would exist even without the application
- Pure business logic
Benefits
- Maintainability: Easy to modify and extend
- Testability: High test coverage possible
- Flexibility: Easy to swap implementations
- Scalability: Clear separation allows independent scaling
Interview Questions
-
Q: Explain the dependency rule in Clean Architecture
-
A: Dependencies must point inward. Outer layers can depend on inner layers, but inner layers cannot know about outer layers. This ensures business logic remains independent of implementation details.
-
Q: How does Clean Architecture handle database changes?
-
A: Database is treated as a detail in the outer layer. Business rules interact through interfaces (repositories), making it easy to swap database implementations without affecting core logic.
Clean Code Principles
Key Principles
1. Meaningful Names
// Bad
const d = 30; // days
// Good
const elapsedTimeInDays = 30;
const MAX_RETRY_ATTEMPTS = 3;
2. Functions Should Do One Thing
// Bad
function processUserDataAndSendEmail(user) {
// validate user
// save to database
// send email
// log activity
}
// Good
function validateUser(user) { }
function saveUser(user) { }
function sendWelcomeEmail(user) { }
function logUserActivity(user) { }
3. DRY (Don't Repeat Yourself)
- Extract repeated logic into reusable functions
- Use abstraction to eliminate duplication
4. Small Functions
- Functions should be small (ideally < 20 lines)
- Should have few parameters (ideally ≤ 3)
5. Comments
- Code should be self-explanatory
- Comments should explain "why", not "what"
- Avoid redundant comments
// Bad
// Increment i
i++;
// Good
// Retry failed requests with exponential backoff
retryCount++;
6. Error Handling
- Use exceptions rather than return codes
- Don't return null (use Optional, Maybe, or throw exceptions)
- Provide context with exceptions
7. Code Formatting
- Consistent indentation
- Proper spacing
- Group related code together
- Follow language conventions
SOLID Principles
S - Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change.
// Bad
class User {
saveToDatabase() { }
sendEmail() { }
generateReport() { }
}
// Good
class User { }
class UserRepository {
save(user: User) { }
}
class EmailService {
send(user: User) { }
}
class ReportGenerator {
generate(user: User) { }
}
O - Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
// Bad
class PaymentProcessor {
processPayment(type: string) {
if (type === 'credit') { }
else if (type === 'paypal') { }
// Need to modify when adding new payment types
}
}
// Good
interface PaymentMethod {
process(): void;
}
class CreditCardPayment implements PaymentMethod {
process() { }
}
class PayPalPayment implements PaymentMethod {
process() { }
}
L - Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.
I - Interface Segregation Principle (ISP)
No client should be forced to depend on methods it does not use.
// Bad
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
// Good
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
D - Dependency Inversion Principle (DIP)
Depend upon abstractions, not concretions.
// Bad
class MySQLDatabase {
save(data: any) { }
}
class UserService {
private db = new MySQLDatabase();
saveUser(user: User) {
this.db.save(user);
}
}
// Good
interface Database {
save(data: any): void;
}
class MySQLDatabase implements Database {
save(data: any) { }
}
class UserService {
constructor(private db: Database) { }
saveUser(user: User) {
this.db.save(user);
}
}
Test-Driven Development (TDD)
The Three Laws of TDD
- First Law: You may not write production code until you have written a failing unit test
- Second Law: You may not write more of a unit test than is sufficient to fail
- Third Law: You may not write more production code than is sufficient to pass the currently failing test
TDD Cycle (Red-Green-Refactor)
1. RED: Write a failing test
↓
2. GREEN: Write minimal code to pass the test
↓
3. REFACTOR: Clean up code while keeping tests green
↓
Repeat
Benefits
- Better Design: Forces you to think about API before implementation
- Documentation: Tests serve as living documentation
- Confidence: Safe refactoring with comprehensive test coverage
- Fewer Bugs: Issues caught early in development
- Faster Development: Less debugging time in the long run
TDD Best Practices
1. Test One Thing at a Time
// Bad
test('user operations', () => {
expect(createUser()).toBeTruthy();
expect(updateUser()).toBeTruthy();
expect(deleteUser()).toBeTruthy();
});
// Good
test('should create user', () => {
expect(createUser()).toBeTruthy();
});
test('should update user', () => {
expect(updateUser()).toBeTruthy();
});
2. Follow AAA Pattern
- Arrange: Set up test data
- Act: Execute the code under test
- Assert: Verify the results
test('should calculate total price with discount', () => {
// Arrange
const items = [{ price: 100 }, { price: 200 }];
const discount = 0.1;
// Act
const total = calculateTotal(items, discount);
// Assert
expect(total).toBe(270);
});
3. Test Behavior, Not Implementation
// Bad - Testing implementation
test('should call database.save', () => {
const spy = jest.spyOn(database, 'save');
userService.createUser(user);
expect(spy).toHaveBeenCalled();
});
// Good - Testing behavior
test('should persist user', async () => {
const user = await userService.createUser(userData);
const savedUser = await userService.findById(user.id);
expect(savedUser).toEqual(user);
});
Testing Pyramid
/\
/UI\ ← Few (E2E Tests)
/____\
/ \
/Integration\ ← Some (Integration Tests)
/____________\
/ \
/ Unit Tests \ ← Many (Unit Tests)
/________________\
Domain-Driven Design (DDD)
Core Concepts
1. Ubiquitous Language
A common language shared by developers and domain experts
- Use domain terminology in code
- Same terms in conversations, documentation, and code
2. Bounded Context
A boundary within which a particular domain model is defined and applicable
┌─────────────────┐ ┌─────────────────┐
│ Order Context │ │ Shipping │
│ │ │ Context │
│ Order │───▶│ Shipment │
│ OrderItem │ │ Package │
│ Customer │ │ Address │
└─────────────────┘ └─────────────────┘
3. Entities
Objects with a distinct identity that runs through time and different states
class Order {
constructor(
private readonly id: OrderId,
private status: OrderStatus,
private items: OrderItem[]
) {}
// Identity is based on id, not on properties
equals(other: Order): boolean {
return this.id.equals(other.id);
}
}
4. Value Objects
Objects that describe characteristics but have no identity
class Money {
constructor(
private readonly amount: number,
private readonly currency: string
) {}
// Equality based on values
equals(other: Money): boolean {
return this.amount === other.amount &&
this.currency === other.currency;
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
}
5. Aggregates
A cluster of domain objects that can be treated as a single unit
// Order is the Aggregate Root
class Order {
private items: OrderItem[] = [];
addItem(item: OrderItem) {
// Validate business rules
if (this.status !== OrderStatus.DRAFT) {
throw new Error('Cannot modify confirmed order');
}
this.items.push(item);
}
// Only Order can modify OrderItems
// External code cannot directly access items
}
Aggregate Rules:
- One Aggregate Root per aggregate
- References from outside use the root only
- Transaction boundaries align with aggregates
6. Repositories
Abstracts persistence, provides collection-like interface
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
}
7. Domain Events
Something that happened in the domain that domain experts care about
class OrderPlacedEvent {
constructor(
public readonly orderId: OrderId,
public readonly customerId: CustomerId,
public readonly occurredAt: Date
) {}
}
class Order {
place(): void {
// Business logic
this.status = OrderStatus.PLACED;
// Publish event
this.addDomainEvent(
new OrderPlacedEvent(this.id, this.customerId, new Date())
);
}
}
8. Domain Services
Operations that don't belong to a specific entity or value object
class PricingService {
calculateOrderTotal(order: Order, customer: Customer): Money {
const discount = customer.isPremium() ? 0.1 : 0;
return order.subtotal().applyDiscount(discount);
}
}
9. Application Services
Orchestrates domain objects, handles use cases
class PlaceOrderService {
constructor(
private orderRepository: OrderRepository,
private eventBus: EventBus
) {}
async execute(command: PlaceOrderCommand): Promise<void> {
const order = await this.orderRepository.findById(command.orderId);
order.place(); // Domain logic
await this.orderRepository.save(order);
// Publish events
order.domainEvents.forEach(event => this.eventBus.publish(event));
}
}
Strategic Design Patterns
Context Mapping
Describes relationships between bounded contexts:
- Shared Kernel: Two contexts share a subset of the domain model
- Customer/Supplier: Upstream context provides API for downstream
- Conformist: Downstream context conforms to upstream model
- Anticorruption Layer: Translation layer to prevent upstream changes affecting downstream
- Separate Ways: Contexts are completely independent
Microservices Patterns
Core Characteristics
- Independently Deployable: Each service can be deployed without affecting others
- Organized Around Business Capabilities: Services align with business domains
- Decentralized Data Management: Each service owns its data
- Infrastructure Automation: CI/CD, automated testing
- Design for Failure: Handle partial failures gracefully
Decomposition Patterns
1. Decompose by Business Capability
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Order │ │ Inventory │ │ Shipping │
│ Service │ │ Service │ │ Service │
└──────────────┘ └──────────────┘ └──────────────┘
2. Decompose by Subdomain (DDD)
Align microservices with DDD bounded contexts
3. Strangler Fig Pattern
Gradually replace legacy system by putting new services around the edges
Communication Patterns
1. API Gateway
Single entry point for clients
Client
↓
┌─────────────┐
│ API Gateway │
└─────────────┘
↓ ↓ ↓
┌──┐ ┌──┐ ┌──┐
│S1│ │S2│ │S3│
└──┘ └──┘ └──┘
Benefits:
- Simplified client code
- Request routing and composition
- Authentication and authorization
- Rate limiting
2. Backend for Frontend (BFF)
Separate gateway for each client type
Mobile App → [Mobile BFF] → ┐
Web App → [Web BFF] → ├→ Microservices
Desktop App → [Desktop BFF] → ┘
3. Service Mesh
Infrastructure layer for service-to-service communication
Features:
- Service discovery
- Load balancing
- Encryption
- Authentication
- Monitoring
Data Patterns
1. Database per Service
Each microservice has its own database
// Order Service
class OrderService {
private orderDB: Database; // Orders database
}
// Customer Service
class CustomerService {
private customerDB: Database; // Customers database
}
Benefits:
- Loose coupling
- Technology diversity
- Independent scaling
Challenges:
- Data consistency
- Queries across services
- Data duplication
2. Shared Database (Anti-pattern)
Multiple services sharing one database - generally avoided
3. CQRS (Command Query Responsibility Segregation)
Separate models for read and write operations
// Write Model (Commands)
class OrderCommandService {
async placeOrder(command: PlaceOrderCommand) {
// Write to normalized relational DB
}
}
// Read Model (Queries)
class OrderQueryService {
async getOrderDetails(orderId: string) {
// Read from denormalized view/cache
}
}
4. Event Sourcing
Store state changes as sequence of events
// Instead of storing current state
{ orderId: 1, status: 'SHIPPED', total: 100 }
// Store events
[
{ type: 'OrderPlaced', orderId: 1, total: 100 },
{ type: 'OrderPaid', orderId: 1 },
{ type: 'OrderShipped', orderId: 1 }
]
Resilience Patterns
1. Circuit Breaker
Prevent cascading failures
class CircuitBreaker {
private state = 'CLOSED';
private failureCount = 0;
async call(fn: Function) {
if (this.state === 'OPEN') {
throw new Error('Circuit breaker is OPEN');
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onFailure() {
this.failureCount++;
if (this.failureCount >= THRESHOLD) {
this.state = 'OPEN';
setTimeout(() => this.state = 'HALF_OPEN', TIMEOUT);
}
}
}
States:
- CLOSED: Normal operation
- OPEN: Fail fast, don't call service
- HALF_OPEN: Try again after timeout
2. Retry Pattern
Retry failed operations with backoff
async function retryWithBackoff(fn: Function, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
await sleep(Math.pow(2, i) * 1000); // Exponential backoff
}
}
}
3. Bulkhead Pattern
Isolate resources to prevent total failure
// Separate thread pools for different services
const orderServicePool = new ThreadPool(10);
const paymentServicePool = new ThreadPool(10);
// If payment service fails, order service still has resources
4. Timeout Pattern
Prevent indefinite waiting
async function withTimeout(promise: Promise<any>, ms: number) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
);
return Promise.race([promise, timeout]);
}
Observability Patterns
1. Health Check API
app.get('/health', (req, res) => {
const health = {
status: 'UP',
database: checkDatabase(),
cache: checkCache(),
dependencies: checkDependencies()
};
res.json(health);
});
2. Distributed Tracing
Track requests across services
Request ID: abc-123
Service A (10ms) → Service B (20ms) → Service C (15ms)
Total: 45ms
3. Log Aggregation
Centralize logs from all services
Distributed Transactions
The Challenge
In distributed systems, maintaining data consistency across multiple services/databases is difficult because:
- Services have independent databases
- Network can fail
- Services can fail
- Partial failures occur
ACID vs BASE
ACID (Traditional Databases)
- Atomicity: All or nothing
- Consistency: Valid state transitions
- Isolation: Concurrent transactions don't interfere
- Durability: Committed data persists
BASE (Distributed Systems)
- Basically Available: System appears to work most of the time
- Soft state: State may change over time without input
- Eventual consistency: System will become consistent eventually
Two-Phase Commit (2PC)
A distributed transaction protocol for achieving atomic commits
Phases:
Phase 1: Prepare
Coordinator Participants
| |
|----Prepare Request------->|
| |
|<------Vote Yes/No---------|
Phase 2: Commit/Abort
Coordinator Participants
| |
|----Commit/Abort---------->|
| |
|<------Acknowledgment------|
Problems:
- Blocking: Participants wait for coordinator
- Single point of failure: Coordinator fails
- Performance: Requires locks during transaction
- Not suitable for microservices: Tight coupling
Three-Phase Commit (3PC)
Adds a third phase to address 2PC blocking issue
- Still has performance problems
- Rarely used in practice
Saga Pattern
Overview
A saga is a sequence of local transactions where each transaction updates data within a single service and publishes an event/message to trigger the next step. If a step fails, the saga executes compensating transactions to undo changes.
Types of Sagas
1. Choreography-Based Saga
Services communicate through events without central coordination
Order Service Payment Service Inventory Service
| | |
|--OrderCreated--->| |
| | |
| |--PaymentProcessed->|
| | |
|<-----------InventoryReserved---------|
| | |
Advantages:
- Loose coupling
- Simple for basic workflows
- Good for event-driven architectures
Disadvantages:
- Hard to understand workflow
- Difficult to debug
- Risk of cyclic dependencies
Example:
// Order Service
class OrderService {
async createOrder(orderData: CreateOrderDTO) {
const order = await this.orderRepo.save(orderData);
// Publish event
await this.eventBus.publish(new OrderCreatedEvent(order));
return order;
}
@EventHandler(PaymentFailedEvent)
async onPaymentFailed(event: PaymentFailedEvent) {
// Compensating transaction
await this.orderRepo.updateStatus(event.orderId, 'CANCELLED');
}
}
// Payment Service
class PaymentService {
@EventHandler(OrderCreatedEvent)
async onOrderCreated(event: OrderCreatedEvent) {
try {
await this.processPayment(event.order);
await this.eventBus.publish(new PaymentCompletedEvent(event.orderId));
} catch (error) {
await this.eventBus.publish(new PaymentFailedEvent(event.orderId));
}
}
}
2. Orchestration-Based Saga
Central orchestrator directs the saga
Saga Orchestrator
|
┌──────────────┼──────────────┐
↓ ↓ ↓
Order Service Payment Service Inventory Service
Advantages:
- Centralized workflow logic
- Easier to understand and test
- Better for complex workflows
- Explicit saga state
Disadvantages:
- Orchestrator can become complex
- Additional service to maintain
Example:
class OrderSagaOrchestrator {
async execute(orderData: CreateOrderDTO) {
const saga = new OrderSaga();
try {
// Step 1: Create Order
const order = await this.orderService.createOrder(orderData);
saga.setOrderId(order.id);
// Step 2: Process Payment
await this.paymentService.processPayment(order.id, order.total);
saga.setPaymentCompleted();
// Step 3: Reserve Inventory
await this.inventoryService.reserveItems(order.items);
saga.setInventoryReserved();
// Step 4: Confirm Order
await this.orderService.confirmOrder(order.id);
return order;
} catch (error) {
// Compensating transactions in reverse order
await this.compensate(saga);
throw error;
}
}
private async compensate(saga: OrderSaga) {
if (saga.inventoryReserved) {
await this.inventoryService.releaseItems(saga.orderId);
}
if (saga.paymentCompleted) {
await this.paymentService.refund(saga.orderId);
}
if (saga.orderId) {
await this.orderService.cancelOrder(saga.orderId);
}
}
}
Compensating Transactions
Saga compensations must be:
- Idempotent: Can be safely retried
- Retryable: Will eventually succeed
- Reversible: Undo previous step
// Example: Payment Compensation
class PaymentService {
async refundPayment(orderId: string) {
const payment = await this.findByOrderId(orderId);
// Idempotent check
if (payment.status === 'REFUNDED') {
return; // Already refunded
}
try {
await this.paymentGateway.refund(payment.transactionId);
payment.status = 'REFUNDED';
await this.save(payment);
} catch (error) {
// Log and retry later
await this.retryQueue.add({ orderId, action: 'refund' });
throw error;
}
}
}
Saga Execution Coordinator (SEC)
For orchestration-based sagas, use a durable execution coordinator:
interface SagaStep {
execute(): Promise<void>;
compensate(): Promise<void>;
}
class SagaExecutor {
private steps: SagaStep[] = [];
private executedSteps: SagaStep[] = [];
addStep(step: SagaStep) {
this.steps.push(step);
return this;
}
async execute() {
for (const step of this.steps) {
try {
await step.execute();
this.executedSteps.push(step);
await this.saveSagaState(); // Persist progress
} catch (error) {
await this.rollback();
throw error;
}
}
}
private async rollback() {
// Execute compensations in reverse order
for (const step of this.executedSteps.reverse()) {
await step.compensate();
}
}
}
Handling Failures
1. Backward Recovery (Compensating)
Undo completed steps
2. Forward Recovery (Retry)
Retry failed step until it succeeds
class SagaStep {
async executeWithRetry(maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.execute();
} catch (error) {
if (i === maxRetries - 1) {
throw error; // Failed all retries, trigger compensation
}
await sleep(Math.pow(2, i) * 1000);
}
}
}
}
Saga Persistence
Store saga state to handle failures:
interface SagaState {
id: string;
status: 'STARTED' | 'COMPLETED' | 'FAILED' | 'COMPENSATING';
currentStep: number;
data: any;
completedSteps: string[];
createdAt: Date;
updatedAt: Date;
}
class SagaStateRepository {
async save(state: SagaState): Promise<void> {
// Persist to database
}
async recover(sagaId: string): Promise<SagaState> {
// Recover saga state after failure
}
}
CQRS & Event Sourcing
CQRS (Command Query Responsibility Segregation)
Overview
CQRS separates read and write operations into different models. Instead of using the same data model for both queries and commands, you use separate models optimized for each operation type.
Traditional CRUD:
┌─────────────┐
│ Single │
│ Model │◄──── Read & Write
└─────────────┘
CQRS:
┌─────────────┐ ┌─────────────┐
│ Command │ │ Query │
│ Model │ │ Model │
│ (Write) │ │ (Read) │
└─────────────┘ └─────────────┘
│ ▲
│ Synchronization │
└────────────────────────┘
Why CQRS?
| Problem | CQRS Solution |
|---|---|
| Read/Write ratio imbalance | Scale reads independently |
| Complex queries slow down writes | Optimized read models |
| Different validation for R/W | Separate models with different rules |
| Conflicting requirements | Each model optimized for its purpose |
CQRS Components
Commands (Write Side)
// Commands represent intent to change state
interface Command {
readonly type: string;
readonly timestamp: Date;
}
class CreateOrderCommand implements Command {
readonly type = 'CreateOrder';
readonly timestamp = new Date();
constructor(
public readonly customerId: string,
public readonly items: OrderItem[],
public readonly shippingAddress: Address
) {}
}
class CancelOrderCommand implements Command {
readonly type = 'CancelOrder';
readonly timestamp = new Date();
constructor(
public readonly orderId: string,
public readonly reason: string
) {}
}
Command Handlers
interface CommandHandler<T extends Command> {
handle(command: T): Promise<void>;
}
class CreateOrderHandler implements CommandHandler<CreateOrderCommand> {
constructor(
private orderRepository: OrderRepository,
private eventBus: EventBus
) {}
async handle(command: CreateOrderCommand): Promise<void> {
// Validate business rules
await this.validateCustomer(command.customerId);
await this.validateItems(command.items);
// Create aggregate
const order = Order.create(
command.customerId,
command.items,
command.shippingAddress
);
// Persist
await this.orderRepository.save(order);
// Publish events for read model updates
await this.eventBus.publishAll(order.domainEvents);
}
}
Queries (Read Side)
// Queries represent requests for data
interface Query<T> {
readonly type: string;
}
class GetOrderDetailsQuery implements Query<OrderDetailsDTO> {
readonly type = 'GetOrderDetails';
constructor(public readonly orderId: string) {}
}
class GetCustomerOrdersQuery implements Query<OrderSummaryDTO[]> {
readonly type = 'GetCustomerOrders';
constructor(
public readonly customerId: string,
public readonly page: number,
public readonly pageSize: number
) {}
}
Query Handlers
interface QueryHandler<Q extends Query<R>, R> {
handle(query: Q): Promise<R>;
}
class GetOrderDetailsHandler implements QueryHandler<GetOrderDetailsQuery, OrderDetailsDTO> {
constructor(private readDb: ReadDatabase) {}
async handle(query: GetOrderDetailsQuery): Promise<OrderDetailsDTO> {
// Read from optimized read model (denormalized view)
return await this.readDb.orderDetails.findOne({
orderId: query.orderId
});
}
}
CQRS Data Synchronization
Option 1: Synchronous (Same Database)
class OrderCommandHandler {
async handle(command: CreateOrderCommand) {
await this.writeDb.transaction(async (tx) => {
// Write to normalized tables
await tx.orders.insert(order);
await tx.orderItems.insertMany(items);
// Update denormalized read model in same transaction
await tx.orderReadModel.insert({
orderId: order.id,
customerName: customer.name,
totalAmount: order.total,
itemCount: items.length,
status: order.status
});
});
}
}
Option 2: Asynchronous (Event-Driven)
// Write side publishes events
class OrderCommandHandler {
async handle(command: CreateOrderCommand) {
const order = Order.create(...);
await this.orderRepository.save(order);
// Publish event
await this.eventBus.publish(new OrderCreatedEvent(order));
}
}
// Read side subscribes to events
class OrderReadModelProjection {
@EventHandler(OrderCreatedEvent)
async onOrderCreated(event: OrderCreatedEvent) {
await this.readDb.orderReadModel.insert({
orderId: event.order.id,
customerName: event.order.customerName,
totalAmount: event.order.total,
itemCount: event.order.items.length,
status: 'CREATED',
createdAt: event.occurredAt
});
}
@EventHandler(OrderShippedEvent)
async onOrderShipped(event: OrderShippedEvent) {
await this.readDb.orderReadModel.update(
{ orderId: event.orderId },
{ status: 'SHIPPED', shippedAt: event.occurredAt }
);
}
}
Option 3: Different Databases
Write Model (PostgreSQL) Read Model (Elasticsearch)
┌──────────────────────┐ ┌──────────────────────┐
│ orders │ │ order_search_index │
│ order_items │────>│ (denormalized, │
│ customers │ │ optimized for │
│ (normalized) │ │ full-text search) │
└──────────────────────┘ └──────────────────────┘
│ ▲
│ Event Bus / CDC │
└──────────────────────────────┘
CQRS Consistency
| Approach | Consistency | Complexity | Use Case |
|---|---|---|---|
| Same DB, same transaction | Strong | Low | Simple apps |
| Same DB, async update | Eventual | Medium | Moderate load |
| Different DBs | Eventual | High | High scalability |
Handling Eventual Consistency
// Client-side: Optimistic UI
class OrderService {
async createOrder(data: CreateOrderDTO): Promise<OrderDTO> {
// Create order
const orderId = await this.commandBus.execute(
new CreateOrderCommand(data)
);
// Return immediately with known data
// (read model might not be updated yet)
return {
id: orderId,
status: 'PENDING',
items: data.items,
// ... data we already know
};
}
}
// Polling for updates
async function waitForReadModel(orderId: string, maxWait = 5000) {
const start = Date.now();
while (Date.now() - start < maxWait) {
const order = await queryBus.execute(new GetOrderQuery(orderId));
if (order) return order;
await sleep(100);
}
throw new Error('Read model not updated in time');
}
When to Use CQRS
✅ Good Fit:
- High read/write ratio disparity
- Complex queries requiring joins across aggregates
- Different scaling requirements for reads and writes
- Event-driven architectures
- Systems with multiple read representations
❌ Avoid When:
- Simple CRUD applications
- Small teams with limited resources
- Strong consistency is critical everywhere
- Low traffic systems
Event Sourcing
Overview
Event Sourcing stores the state of an entity as a sequence of events rather than the current state. The current state is derived by replaying all events.
Traditional (State-Based):
┌─────────────────────────────────┐
│ Order #123 │
│ status: SHIPPED │
│ total: $150 │
│ updated_at: 2024-01-15 │
└─────────────────────────────────┘
Event Sourcing (Event-Based):
┌─────────────────────────────────┐
│ Event 1: OrderCreated │
│ orderId: 123, total: $150 │
├─────────────────────────────────┤
│ Event 2: PaymentReceived │
│ orderId: 123, amount: $150 │
├─────────────────────────────────┤
│ Event 3: OrderShipped │
│ orderId: 123, trackingNo: X │
└─────────────────────────────────┘
↓ Replay events
Current State: { status: SHIPPED, total: $150, ... }
Core Concepts
Events
// Events are immutable facts that happened
interface DomainEvent {
readonly eventId: string;
readonly aggregateId: string;
readonly aggregateType: string;
readonly eventType: string;
readonly version: number;
readonly occurredAt: Date;
readonly data: unknown;
}
class OrderCreatedEvent implements DomainEvent {
readonly eventType = 'OrderCreated';
constructor(
public readonly eventId: string,
public readonly aggregateId: string,
public readonly version: number,
public readonly occurredAt: Date,
public readonly data: {
customerId: string;
items: OrderItem[];
totalAmount: number;
}
) {}
}
class OrderItemAddedEvent implements DomainEvent {
readonly eventType = 'OrderItemAdded';
// ...
}
class OrderCancelledEvent implements DomainEvent {
readonly eventType = 'OrderCancelled';
// ...
}
Event Store
interface EventStore {
// Append events for an aggregate
append(
aggregateId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void>;
// Load all events for an aggregate
loadEvents(aggregateId: string): Promise<DomainEvent[]>;
// Load events from a specific version
loadEventsFromVersion(
aggregateId: string,
fromVersion: number
): Promise<DomainEvent[]>;
// Subscribe to all events (for projections)
subscribe(handler: (event: DomainEvent) => Promise<void>): void;
}
// PostgreSQL Implementation
class PostgresEventStore implements EventStore {
async append(
aggregateId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
await this.db.transaction(async (tx) => {
// Optimistic concurrency check
const currentVersion = await tx.query(
'SELECT MAX(version) FROM events WHERE aggregate_id = $1',
[aggregateId]
);
if (currentVersion !== expectedVersion) {
throw new ConcurrencyError(
`Expected version ${expectedVersion}, found ${currentVersion}`
);
}
// Append events
for (const event of events) {
await tx.query(
`INSERT INTO events
(event_id, aggregate_id, event_type, version, data, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[event.eventId, aggregateId, event.eventType,
event.version, JSON.stringify(event.data), event.occurredAt]
);
}
});
}
async loadEvents(aggregateId: string): Promise<DomainEvent[]> {
const rows = await this.db.query(
'SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version',
[aggregateId]
);
return rows.map(this.deserializeEvent);
}
}
Event-Sourced Aggregate
abstract class EventSourcedAggregate {
private uncommittedEvents: DomainEvent[] = [];
protected version: number = 0;
// Apply event and update state
protected apply(event: DomainEvent): void {
this.when(event);
this.version = event.version;
this.uncommittedEvents.push(event);
}
// State mutation based on event type
protected abstract when(event: DomainEvent): void;
// Get events to persist
getUncommittedEvents(): DomainEvent[] {
return [...this.uncommittedEvents];
}
// Clear after persistence
clearUncommittedEvents(): void {
this.uncommittedEvents = [];
}
// Reconstitute from events
static rehydrate<T extends EventSourcedAggregate>(
events: DomainEvent[],
factory: () => T
): T {
const aggregate = factory();
events.forEach(event => {
aggregate.when(event);
aggregate.version = event.version;
});
return aggregate;
}
}
class Order extends EventSourcedAggregate {
private id: string;
private status: OrderStatus;
private items: OrderItem[] = [];
private totalAmount: number = 0;
// Command methods create events
static create(customerId: string, items: OrderItem[]): Order {
const order = new Order();
const total = items.reduce((sum, item) => sum + item.price, 0);
order.apply(new OrderCreatedEvent(
uuid(),
uuid(), // aggregateId
1, // version
new Date(),
{ customerId, items, totalAmount: total }
));
return order;
}
addItem(item: OrderItem): void {
if (this.status !== OrderStatus.DRAFT) {
throw new Error('Cannot add items to non-draft order');
}
this.apply(new OrderItemAddedEvent(
uuid(),
this.id,
this.version + 1,
new Date(),
{ item }
));
}
cancel(reason: string): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel shipped order');
}
this.apply(new OrderCancelledEvent(
uuid(),
this.id,
this.version + 1,
new Date(),
{ reason }
));
}
// State mutations
protected when(event: DomainEvent): void {
switch (event.eventType) {
case 'OrderCreated':
this.id = event.aggregateId;
this.status = OrderStatus.DRAFT;
this.items = event.data.items;
this.totalAmount = event.data.totalAmount;
break;
case 'OrderItemAdded':
this.items.push(event.data.item);
this.totalAmount += event.data.item.price;
break;
case 'OrderCancelled':
this.status = OrderStatus.CANCELLED;
break;
}
}
}
Repository with Event Sourcing
class EventSourcedOrderRepository {
constructor(private eventStore: EventStore) {}
async findById(orderId: string): Promise<Order | null> {
const events = await this.eventStore.loadEvents(orderId);
if (events.length === 0) {
return null;
}
return Order.rehydrate(events, () => new Order());
}
async save(order: Order): Promise<void> {
const events = order.getUncommittedEvents();
if (events.length === 0) {
return;
}
const expectedVersion = order.version - events.length;
await this.eventStore.append(
order.id,
events,
expectedVersion
);
order.clearUncommittedEvents();
}
}
Projections (Read Models)
Projections build read-optimized views from events:
// Projection for order list view
class OrderListProjection {
constructor(
private readDb: Database,
private eventStore: EventStore
) {}
async start(): Promise<void> {
// Subscribe to event stream
this.eventStore.subscribe(async (event) => {
await this.project(event);
});
}
private async project(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case 'OrderCreated':
await this.readDb.orderList.insert({
orderId: event.aggregateId,
customerId: event.data.customerId,
totalAmount: event.data.totalAmount,
itemCount: event.data.items.length,
status: 'DRAFT',
createdAt: event.occurredAt
});
break;
case 'OrderShipped':
await this.readDb.orderList.update(
{ orderId: event.aggregateId },
{ status: 'SHIPPED', shippedAt: event.occurredAt }
);
break;
case 'OrderCancelled':
await this.readDb.orderList.update(
{ orderId: event.aggregateId },
{ status: 'CANCELLED' }
);
break;
}
}
// Rebuild projection from scratch
async rebuild(): Promise<void> {
await this.readDb.orderList.deleteAll();
const allEvents = await this.eventStore.loadAllEvents();
for (const event of allEvents) {
await this.project(event);
}
}
}
// Multiple projections from same events
class OrderAnalyticsProjection {
async project(event: DomainEvent): Promise<void> {
if (event.eventType === 'OrderCreated') {
await this.analyticsDb.dailyOrders.increment(
event.occurredAt.toDateString(),
event.data.totalAmount
);
}
}
}
Snapshots
For aggregates with many events, use snapshots to improve load performance:
interface Snapshot {
aggregateId: string;
version: number;
state: unknown;
createdAt: Date;
}
class SnapshotStore {
async save(snapshot: Snapshot): Promise<void> {
await this.db.snapshots.upsert(snapshot);
}
async load(aggregateId: string): Promise<Snapshot | null> {
return this.db.snapshots.findOne({ aggregateId });
}
}
class EventSourcedOrderRepository {
constructor(
private eventStore: EventStore,
private snapshotStore: SnapshotStore,
private snapshotFrequency: number = 100
) {}
async findById(orderId: string): Promise<Order | null> {
// Try to load snapshot first
const snapshot = await this.snapshotStore.load(orderId);
let events: DomainEvent[];
let order: Order;
if (snapshot) {
// Load only events after snapshot
events = await this.eventStore.loadEventsFromVersion(
orderId,
snapshot.version + 1
);
order = Order.fromSnapshot(snapshot.state);
} else {
// Load all events
events = await this.eventStore.loadEvents(orderId);
order = new Order();
}
// Apply remaining events
events.forEach(event => order.applyFromHistory(event));
return order;
}
async save(order: Order): Promise<void> {
const events = order.getUncommittedEvents();
await this.eventStore.append(order.id, events, order.version - events.length);
// Create snapshot if needed
if (order.version % this.snapshotFrequency === 0) {
await this.snapshotStore.save({
aggregateId: order.id,
version: order.version,
state: order.toSnapshot(),
createdAt: new Date()
});
}
order.clearUncommittedEvents();
}
}
Event Versioning & Migration
Handle schema evolution:
// Event Upcasting - transform old events to new format
interface EventUpcaster {
eventType: string;
fromVersion: number;
toVersion: number;
upcast(event: DomainEvent): DomainEvent;
}
class OrderCreatedV1ToV2Upcaster implements EventUpcaster {
eventType = 'OrderCreated';
fromVersion = 1;
toVersion = 2;
upcast(event: DomainEvent): DomainEvent {
// V1 had 'amount', V2 has 'totalAmount' and 'currency'
return {
...event,
schemaVersion: 2,
data: {
...event.data,
totalAmount: event.data.amount,
currency: 'USD', // Default for old events
}
};
}
}
class EventUpcasterChain {
private upcasters: Map<string, EventUpcaster[]> = new Map();
register(upcaster: EventUpcaster): void {
const key = upcaster.eventType;
const existing = this.upcasters.get(key) || [];
existing.push(upcaster);
existing.sort((a, b) => a.fromVersion - b.fromVersion);
this.upcasters.set(key, existing);
}
upcast(event: DomainEvent, targetVersion: number): DomainEvent {
const upcasters = this.upcasters.get(event.eventType) || [];
let current = event;
for (const upcaster of upcasters) {
if (current.schemaVersion >= targetVersion) break;
if (current.schemaVersion === upcaster.fromVersion) {
current = upcaster.upcast(current);
}
}
return current;
}
}
Event Sourcing + CQRS Combined
┌─────────────────────────────────────────────────────────────┐
│ WRITE SIDE │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ Command │───>│ Aggregate│───>│ Event Store │ │
│ │ Handler │ │ │ │ (append-only log) │ │
│ └──────────┘ └──────────┘ └──────────┬──────────┘ │
└──────────────────────────────────────────────┼──────────────┘
│
Event Bus │
│
┌──────────────────────────────────────────────┼──────────────┐
│ READ SIDE ▼ │
│ ┌───────────────┐ ┌───────────────────────────────┐ │
│ │ Query Handler │◄───│ Read Database │ │
│ └───────────────┘ │ (Projections/Views) │ │
│ │ ┌─────────┐ ┌─────────────┐ │ │
│ │ │Order │ │Order │ │ │
│ │ │List │ │Analytics │ │ │
│ │ │View │ │Dashboard │ │ │
│ │ └─────────┘ └─────────────┘ │ │
│ └───────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
// Complete flow example
class OrderApplicationService {
constructor(
private orderRepository: EventSourcedOrderRepository,
private eventBus: EventBus
) {}
async createOrder(command: CreateOrderCommand): Promise<string> {
// Create aggregate (generates events internally)
const order = Order.create(
command.customerId,
command.items
);
// Persist events to event store
await this.orderRepository.save(order);
// Publish events for projections
for (const event of order.getUncommittedEvents()) {
await this.eventBus.publish(event);
}
return order.id;
}
}
// Projections update read models
class ReadModelUpdater {
@EventHandler(OrderCreatedEvent)
async handle(event: OrderCreatedEvent) {
// Update multiple read models
await Promise.all([
this.orderListProjection.project(event),
this.customerOrdersProjection.project(event),
this.analyticsProjection.project(event)
]);
}
}
Benefits of Event Sourcing
| Benefit | Description |
|---|---|
| Complete Audit Trail | Every change is recorded |
| Time Travel | Reconstruct state at any point in time |
| Debug Production | Replay events to understand issues |
| Event-Driven Integration | Natural fit for event-driven architecture |
| Flexible Projections | Build any view from events |
| No Data Loss | Events are append-only |
Challenges of Event Sourcing
| Challenge | Mitigation |
|---|---|
| Learning Curve | Training, start with simple aggregates |
| Event Schema Evolution | Upcasting, versioning |
| Query Performance | Snapshots, CQRS with read models |
| Storage Growth | Archiving, compaction strategies |
| Eventual Consistency | UI patterns, proper user expectations |
| Complexity | Only use where benefits outweigh costs |
When to Use Event Sourcing
✅ Good Fit:
- Audit requirements (finance, healthcare, legal)
- Complex domain with business rules
- Need for temporal queries ("what was the state on X date?")
- Event-driven microservices
- Undo/redo functionality needed
❌ Avoid When:
- Simple CRUD operations
- No audit requirements
- Strong consistency required everywhere
- Team unfamiliar with the pattern
- Tight deadlines without time to learn
CQRS & Event Sourcing Interview Questions
Q: What is the difference between CQRS and Event Sourcing?
A: CQRS separates read and write models - you can use it without Event Sourcing (just different databases/models for reads and writes). Event Sourcing stores state as events - you can use it without CQRS. They complement each other well but are independent patterns.
Q: How do you handle eventual consistency in CQRS?
A: Several strategies:
- Optimistic UI: Show expected state immediately
- Polling/WebSocket: Client checks for updates
- Correlation IDs: Track command to query completion
- User expectations: Design UI to communicate async nature
Q: How do you deal with event schema changes?
A: Event versioning strategies:
- Upcasting: Transform old events to new format when reading
- Weak schema: Use flexible schemas (JSON) with defaults
- Copy-transform: Create new event stream with migrated events
- Multiple handlers: Handle both old and new event versions
Q: What happens if a projection fails?
A:
- Retry: Most failures are transient
- Dead letter queue: Store failed events for investigation
- Rebuild: Projections can be rebuilt from events
- Idempotent handlers: Process same event multiple times safely
Q: How do you ensure exactly-once processing?
A: True exactly-once is impossible, but you can achieve effective exactly-once:
- Idempotent consumers: Same event produces same result
- Deduplication: Track processed event IDs
- Transactional outbox: Atomically save state and publish events
Q: When would you NOT use Event Sourcing?
A: Avoid when:
- Simple CRUD with no audit needs
- No temporal query requirements
- Team lacks experience and timeline is tight
- Strong consistency is critical everywhere
- Storage constraints for high-volume events
Q: How do snapshots work and when should you use them?
A: Snapshots store aggregate state at a point in time. Instead of replaying all events, load snapshot + events after it. Use when:
- Aggregates have many events (100+)
- Load time becomes noticeable
- Frequency depends on access patterns and event volume
Q: How do you handle commands that need data from multiple aggregates?
A: Options:
- Domain service: Coordinate between aggregates
- Saga/Process manager: Orchestrate multi-aggregate operations
- Read model: Use eventually consistent read model for validation
- Aggregate redesign: Reconsider aggregate boundaries
Q: Can you rebuild projections? How?
A: Yes, one of Event Sourcing's key benefits:
- Delete existing projection data
- Replay all events from the beginning
- Apply each event to rebuild state
- Consider checkpointing for large event streams
Q: How do you test Event Sourced systems?
A:
// Given-When-Then style
describe('Order', () => {
it('should cancel order', () => {
// Given: existing events
const events = [
new OrderCreatedEvent(...),
new PaymentReceivedEvent(...)
];
// When: command executed
const order = Order.rehydrate(events);
order.cancel('Customer request');
// Then: new events generated
const newEvents = order.getUncommittedEvents();
expect(newEvents).toContainEqual(
expect.objectContaining({ eventType: 'OrderCancelled' })
);
});
});
Other Important Patterns
1. CQRS (Command Query Responsibility Segregation)
Separate read and write models
// Command Side (Write)
interface CommandHandler<T> {
handle(command: T): Promise<void>;
}
class CreateOrderHandler implements CommandHandler<CreateOrderCommand> {
async handle(command: CreateOrderCommand) {
const order = new Order(command);
await this.repository.save(order);
await this.eventBus.publish(new OrderCreatedEvent(order));
}
}
// Query Side (Read)
interface QueryHandler<T, R> {
handle(query: T): Promise<R>;
}
class GetOrderDetailsHandler implements QueryHandler<GetOrderDetailsQuery, OrderDetailsDTO> {
async handle(query: GetOrderDetailsQuery) {
// Read from optimized read model/view
return await this.readModel.getOrderDetails(query.orderId);
}
}
2. Event Sourcing
Store state as sequence of events
class OrderAggregate {
private events: DomainEvent[] = [];
private state: OrderState;
// Recreate state from events
static fromEvents(events: DomainEvent[]): OrderAggregate {
const order = new OrderAggregate();
events.forEach(event => order.apply(event));
return order;
}
placeOrder(items: OrderItem[]) {
const event = new OrderPlacedEvent(this.id, items);
this.applyAndRecord(event);
}
private apply(event: DomainEvent) {
// Update state based on event
if (event instanceof OrderPlacedEvent) {
this.state.status = 'PLACED';
this.state.items = event.items;
}
}
private applyAndRecord(event: DomainEvent) {
this.apply(event);
this.events.push(event);
}
}
3. Strangler Fig Pattern
Gradually replace legacy system
Phase 1: Phase 2: Phase 3:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Legacy │ │ Legacy │ │ New │
│ System │ │ (Partial)│ │ System │
└─────────┘ ├─────────┤ └─────────┘
│ New │
│ Features│
└─────────┘
4. Anti-Corruption Layer (ACL)
Translate between different bounded contexts
// Legacy System Interface
interface LegacyOrderService {
createOrder(data: any): LegacyOrder;
}
// Anti-Corruption Layer
class OrderACL {
constructor(private legacyService: LegacyOrderService) {}
createOrder(modernOrder: Order): Order {
// Translate modern model to legacy format
const legacyData = this.toLegacyFormat(modernOrder);
// Call legacy system
const legacyOrder = this.legacyService.createOrder(legacyData);
// Translate back to modern model
return this.toModernFormat(legacyOrder);
}
private toLegacyFormat(order: Order): any { }
private toModernFormat(legacyOrder: LegacyOrder): Order { }
}
5. API Composition
Combine data from multiple services
class OrderDetailsComposer {
constructor(
private orderService: OrderService,
private customerService: CustomerService,
private inventoryService: InventoryService
) {}
async getOrderDetails(orderId: string): Promise<OrderDetailsDTO> {
// Parallel requests to multiple services
const [order, customer, inventory] = await Promise.all([
this.orderService.getOrder(orderId),
this.customerService.getCustomer(order.customerId),
this.inventoryService.getInventoryStatus(order.items)
]);
// Compose response
return {
order,
customer,
inventoryStatus: inventory
};
}
}
6. Sidecar Pattern
Deploy helper components alongside main application
┌──────────────────┐
│ Main Container │
├──────────────────┤
│ Sidecar Container│
│ - Logging │
│ - Monitoring │
│ - Proxy │
└──────────────────┘
7. Ambassador Pattern
Proxy for external service communication
class DatabaseAmbassador {
private circuitBreaker: CircuitBreaker;
private cache: Cache;
async query(sql: string): Promise<any> {
// Check cache
const cached = await this.cache.get(sql);
if (cached) return cached;
// Circuit breaker protection
return await this.circuitBreaker.execute(async () => {
const result = await this.database.query(sql);
await this.cache.set(sql, result);
return result;
});
}
}
8. Adapter Pattern
Convert interface to match client expectations
// Third-party payment service
class StripePaymentService {
charge(amount: number, token: string) { }
}
// Our payment interface
interface PaymentGateway {
processPayment(payment: Payment): Promise<void>;
}
// Adapter
class StripeAdapter implements PaymentGateway {
constructor(private stripe: StripePaymentService) {}
async processPayment(payment: Payment): Promise<void> {
await this.stripe.charge(
payment.amount,
payment.paymentToken
);
}
}
9. Outbox Pattern
Ensure reliable event publishing
// Save entity and event in same transaction
class OrderService {
async createOrder(orderData: CreateOrderDTO) {
await this.db.transaction(async (tx) => {
// Save order
const order = await tx.orders.save(orderData);
// Save event to outbox table
await tx.outbox.save({
eventType: 'OrderCreated',
aggregateId: order.id,
payload: order,
status: 'PENDING'
});
});
}
}
// Separate process polls outbox and publishes events
class OutboxPublisher {
async publishPendingEvents() {
const events = await this.outbox.findPending();
for (const event of events) {
await this.eventBus.publish(event.payload);
await this.outbox.markAsPublished(event.id);
}
}
}
10. Backends for Frontends (BFF)
Create specialized backends for different clients
// Mobile BFF
class MobileBFF {
async getHomePage(userId: string) {
// Optimized for mobile: minimal data
return {
recentOrders: await this.orderService.getRecent(userId, 5),
notifications: await this.notificationService.getUnread(userId)
};
}
}
// Web BFF
class WebBFF {
async getHomePage(userId: string) {
// More comprehensive data for web
return {
recentOrders: await this.orderService.getRecent(userId, 20),
notifications: await this.notificationService.getAll(userId),
recommendations: await this.recommendationService.get(userId),
analytics: await this.analyticsService.getUserStats(userId)
};
}
}
Common Interview Questions
Authentication & Authorization
Q: What's the difference between OAuth 2.0 and OIDC?
A: OAuth 2.0 is for authorization (access to resources). OIDC adds an identity layer on top, providing authentication (who the user is) via ID tokens.
Q: When would you use SAML vs OIDC?
A: SAML for enterprise/legacy systems, XML-based. OIDC for modern apps, mobile-friendly, JSON/JWT-based. OIDC is lighter and better for SPAs/mobile.
Q: How do you implement SSO across microservices?
A: Use a centralized Identity Provider (IdP) with OIDC. Services validate tokens independently. Consider token introspection or shared JWT validation keys.
Clean Architecture
Q: How do you handle cross-cutting concerns in Clean Architecture?
A: Use the Dependency Inversion Principle. Define interfaces in the inner layers (e.g., logging, authentication interfaces in the application layer) and implement them in the outer layers. Inject implementations through constructors.
Microservices
Q: How do you handle distributed transactions in microservices?
A: Use the Saga pattern with either choreography or orchestration. Each service manages its local transaction and publishes events. Implement compensating transactions for rollback scenarios.
Q: How do you maintain data consistency across services?
A: Use eventual consistency with domain events. Services subscribe to events and update their local data. Use the Outbox pattern to ensure reliable event publishing.
DDD
Q: What's the difference between Entity and Value Object?
A: Entities have identity and lifecycle (tracked by ID). Value Objects have no identity and are defined by their attributes. Value Objects are immutable.
Q: When should you use a Domain Service vs Application Service?
A: Domain Services contain business logic that doesn't naturally fit in an entity or value object. Application Services orchestrate use cases and coordinate between domain objects and infrastructure.
Sagas
Q: Choreography vs Orchestration - when to use each?
A:
- Choreography: Simple workflows, event-driven architecture, loose coupling
- Orchestration: Complex workflows, need for explicit workflow state, easier testing and debugging
Q: How do you ensure saga compensations are reliable?
A: Make compensations idempotent, retryable, and persist saga state. Use message queues for reliability and implement monitoring/alerting for failed compensations.
Best Practices Summary
Architecture
- Start with a modular monolith, extract to microservices when needed
- Define clear bounded contexts
- Use domain events for cross-context communication
- Implement proper abstraction layers
Code Quality
- Follow SOLID principles
- Write self-documenting code
- Maintain high test coverage
- Refactor continuously
Testing
- Practice TDD
- Follow testing pyramid (many unit, some integration, few E2E)
- Test behavior, not implementation
- Make tests maintainable
Distributed Systems
- Design for failure
- Implement circuit breakers and retries
- Use eventual consistency where appropriate
- Monitor and trace across services
- Handle idempotency
Data Management
- Database per service in microservices
- Use saga pattern for distributed transactions
- Implement proper event sourcing when needed
- Consider CQRS for complex read/write requirements
Authentication & Authorization
Overview
Understanding authentication (AuthN) and authorization (AuthZ) protocols is crucial for designing secure distributed systems.
| Aspect | Authentication (AuthN) | Authorization (AuthZ) |
|---|---|---|
| Question | "Who are you?" | "What can you do?" |
| Purpose | Verify identity | Grant permissions |
| Examples | Login, MFA | Access control, roles |
Protocol Comparison
| Protocol | Type | Token Format | Best For | Complexity |
|---|---|---|---|---|
| OAuth 2.0 | Authorization | Access Token (opaque/JWT) | API access, third-party apps | Medium |
| OIDC | Authentication + Authorization | JWT (ID Token + Access Token) | Modern apps, SSO | Medium |
| SAML 2.0 | Authentication + Authorization | XML Assertions | Enterprise SSO | High |
| JWT | Token Format | JSON Web Token | Stateless auth | Low |
| Basic Auth | Authentication | Base64 encoded | Simple APIs, internal | Low |
| API Keys | Authorization | String | Service-to-service | Low |
OAuth 2.0 (Open Authorization)
Overview
OAuth 2.0 is an authorization framework that enables third-party applications to obtain limited access to user resources without exposing credentials.
Key Concepts
- Resource Owner: User who owns the data
- Client: Application requesting access
- Authorization Server: Issues access tokens
- Resource Server: Hosts protected resources
Grant Types
1. Authorization Code (Most Secure)
Best for: Server-side web applications
┌──────────┐ ┌───────────────┐
│ User │ │ Authorization │
│ (Browser)│ │ Server │
└────┬─────┘ └───────┬───────┘
│ │
│ 1. Click "Login with Google" │
│─────────────────────────────────────────> │
│ │
│ 2. Redirect to authorization page │
│ <─────────────────────────────────────────│
│ │
│ 3. User authenticates & consents │
│─────────────────────────────────────────> │
│ │
│ 4. Redirect with authorization code │
│ <─────────────────────────────────────────│
│ │
┌────┴─────┐ ┌───────┴───────┐
│ Client │ 5. Exchange code for tokens │ Authorization │
│ Server │─────────────────────────────>│ Server │
│ │ 6. Access Token + Refresh │ │
│ │<─────────────────────────────│ │
└──────────┘ └───────────────┘
// Step 1: Redirect to authorization
const authUrl = `https://auth.example.com/authorize?
response_type=code&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=read:user&
state=${generateState()}`;
// Step 4: Handle callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRF
if (!verifyState(state)) {
return res.status(403).send('Invalid state');
}
// Step 5: Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Store tokens securely
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
});
2. Authorization Code with PKCE
Best for: Mobile apps, SPAs (public clients)
// Generate PKCE values
const codeVerifier = generateRandomString(128);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));
// Authorization request includes challenge
const authUrl = `https://auth.example.com/authorize?
response_type=code&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
code_challenge=${codeChallenge}&
code_challenge_method=S256`;
// Token exchange includes verifier
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier // Proves we initiated the request
})
});
3. Client Credentials
Best for: Service-to-service (machine-to-machine)
// No user involved - just service authentication
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'read:data write:data'
})
});
const { access_token } = await tokenResponse.json();
4. Resource Owner Password (Legacy - Avoid)
Only for: Trusted first-party apps, migration scenarios
// User credentials sent directly - NOT recommended
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'password',
username: 'user@example.com',
password: 'userpassword',
client_id: CLIENT_ID,
scope: 'read:user'
})
});
5. Device Code
Best for: Input-constrained devices (Smart TV, CLI tools)
// Step 1: Request device code
const deviceCodeResponse = await fetch('https://auth.example.com/device/code', {
method: 'POST',
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: 'read:user'
})
});
const { device_code, user_code, verification_uri } = await deviceCodeResponse.json();
console.log(`Go to ${verification_uri} and enter code: ${user_code}`);
// Step 2: Poll for token
const pollForToken = async () => {
while (true) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: device_code,
client_id: CLIENT_ID
})
});
const data = await response.json();
if (data.access_token) return data;
if (data.error === 'authorization_pending') {
await sleep(5000);
continue;
}
throw new Error(data.error);
}
};
Token Types
| Token | Purpose | Lifetime | Storage |
|---|---|---|---|
| Access Token | API authorization | Short (15min-1hr) | Memory |
| Refresh Token | Get new access tokens | Long (days-months) | Secure storage |
OpenID Connect (OIDC)
Overview
OIDC is an identity layer built on top of OAuth 2.0. While OAuth 2.0 handles authorization, OIDC adds authentication by providing user identity information.
┌─────────────────────────────────────────────────┐
│ OIDC │
│ ┌─────────────────────────────────────────┐ │
│ │ OAuth 2.0 │ │
│ │ (Authorization - Access Tokens) │ │
│ └─────────────────────────────────────────┘ │
│ + ID Token (JWT with user identity) │
│ + UserInfo Endpoint │
│ + Standard Claims (name, email, etc.) │
└─────────────────────────────────────────────────┘
Key Additions Over OAuth 2.0
| Feature | OAuth 2.0 | OIDC |
|---|---|---|
| ID Token | ❌ | ✅ JWT with user info |
| UserInfo Endpoint | ❌ | ✅ /userinfo |
| Standard Scopes | Custom | openid, profile, email |
| Discovery | Manual | /.well-known/openid-configuration |
| User Identity | Not defined | Standardized claims |
ID Token (JWT)
// ID Token Structure
{
// Header
"alg": "RS256",
"typ": "JWT",
"kid": "key-id-123"
}
.
{
// Payload (Claims)
"iss": "https://auth.example.com", // Issuer
"sub": "user-123", // Subject (unique user ID)
"aud": "my-client-id", // Audience
"exp": 1735689600, // Expiration
"iat": 1735686000, // Issued at
"nonce": "random-nonce", // Prevents replay attacks
// Standard Claims
"name": "John Doe",
"email": "john@example.com",
"email_verified": true,
"picture": "https://example.com/photo.jpg"
}
.
[signature]
OIDC Flow
// Step 1: Authorization request (note: scope includes 'openid')
const authUrl = `https://auth.example.com/authorize?
response_type=code&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=openid profile email&
state=${state}&
nonce=${nonce}`;
// Step 2: Exchange code for tokens (returns ID token + access token)
const tokens = await exchangeCode(code);
// Step 3: Validate ID token
const idToken = tokens.id_token;
const decoded = await validateIdToken(idToken, {
issuer: 'https://auth.example.com',
audience: CLIENT_ID,
nonce: storedNonce
});
// Step 4: Get user info (optional - for additional claims)
const userInfo = await fetch('https://auth.example.com/userinfo', {
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});
Standard Scopes & Claims
| Scope | Claims Returned |
|---|---|
openid | sub (required for OIDC) |
profile | name, family_name, given_name, picture, gender, birthdate, locale |
email | email, email_verified |
address | address |
phone | phone_number, phone_number_verified |
SAML 2.0 (Security Assertion Markup Language)
Overview
SAML is an XML-based standard for exchanging authentication and authorization data between an Identity Provider (IdP) and a Service Provider (SP).
Key Concepts
- Identity Provider (IdP): Authenticates users (e.g., Okta, ADFS)
- Service Provider (SP): Application relying on IdP
- Assertion: XML document containing authentication info
- Binding: How SAML messages are transported (HTTP POST, Redirect)
SAML Flow (SP-Initiated)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │ │ SP │ │ IdP │
│ (Browser)│ │(Your App)│ │ (Okta) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Access app │ │
│───────────────────>│ │
│ │ │
│ 2. Redirect with │ │
│ SAML Request │ │
│<───────────────────│ │
│ │ │
│ 3. Forward SAML Request │
│────────────────────────────────────────>│
│ │ │
│ 4. User authenticates │
│<───────────────────────────────────────>│
│ │ │
│ 5. POST SAML Response (Assertion) │
│<────────────────────────────────────────│
│ │ │
│ 6. Forward to SP │ │
│───────────────────>│ │
│ │ │
│ 7. Validate & Create Session │
│<───────────────────│ │
│ │ │
SAML Assertion Structure
<saml:Assertion>
<saml:Issuer>https://idp.example.com</saml:Issuer>
<saml:Subject>
<saml:NameID>user@example.com</saml:NameID>
</saml:Subject>
<saml:Conditions NotBefore="..." NotOnOrAfter="...">
<saml:AudienceRestriction>
<saml:Audience>https://sp.example.com</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="...">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:Password
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>user@example.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="role">
<saml:AttributeValue>admin</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
Single Sign-On (SSO)
Overview
SSO allows users to authenticate once and access multiple applications without re-entering credentials.
SSO Architectures
1. Centralized IdP (Most Common)
┌─────────────────┐
│ Identity │
│ Provider (IdP) │
│ (Okta/Auth0) │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ App A │ │ App B │ │ App C │
│ (SP) │ │ (SP) │ │ (SP) │
└─────────┘ └─────────┘ └─────────┘
2. Token-Based SSO (JWT)
// Central Auth Service issues JWT
class AuthService {
login(credentials: Credentials): Promise<{ token: string }> {
const user = await this.validateCredentials(credentials);
const token = jwt.sign({
sub: user.id,
email: user.email,
roles: user.roles,
apps: ['app-a', 'app-b', 'app-c'] // Authorized apps
}, SECRET_KEY, { expiresIn: '8h' });
return { token };
}
}
// Each app validates the same JWT
class AppA {
validateToken(token: string) {
const decoded = jwt.verify(token, SECRET_KEY);
if (!decoded.apps.includes('app-a')) {
throw new Error('Not authorized for this app');
}
return decoded;
}
}
3. Session-Based SSO (Cookie)
// Shared session across subdomains
// Auth: auth.example.com
// App A: app-a.example.com
// App B: app-b.example.com
// Set cookie on parent domain
res.cookie('session', sessionId, {
domain: '.example.com', // Shared across subdomains
httpOnly: true,
secure: true,
sameSite: 'lax'
});
SSO Implementation Patterns
Pattern 1: Redirect-Based SSO
// App checks authentication
app.use(async (req, res, next) => {
const session = await getSession(req);
if (!session) {
// Redirect to central login
const returnUrl = encodeURIComponent(req.originalUrl);
return res.redirect(`https://sso.example.com/login?return=${returnUrl}`);
}
req.user = session.user;
next();
});
Pattern 2: Token Exchange SSO
// App A has user session, user wants to access App B
class SSOService {
async generateCrossAppToken(userId: string, targetApp: string): Promise<string> {
// Short-lived token for app-to-app navigation
return jwt.sign({
sub: userId,
target: targetApp,
type: 'sso-transfer'
}, SECRET, { expiresIn: '30s' });
}
}
// User clicks link to App B
const ssoToken = await ssoService.generateCrossAppToken(user.id, 'app-b');
redirect(`https://app-b.example.com/sso/callback?token=${ssoToken}`);
JWT (JSON Web Token)
Structure
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT vs Opaque Tokens
| Aspect | JWT | Opaque Token |
|---|---|---|
| Self-contained | ✅ Contains claims | ❌ Reference only |
| Validation | Local (verify signature) | Remote (introspection) |
| Revocation | Difficult (until expiry) | Easy (delete from store) |
| Size | Larger | Small |
| Performance | Better (no network call) | Requires lookup |
JWT Best Practices
// 1. Use appropriate algorithm
const token = jwt.sign(payload, privateKey, {
algorithm: 'RS256', // Asymmetric for distributed systems
expiresIn: '15m' // Short expiration
});
// 2. Validate all claims
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Prevent algorithm confusion
issuer: 'https://auth.example.com',
audience: 'my-api'
});
// 3. Don't store sensitive data
// BAD
{ "password": "secret123" }
// GOOD
{ "sub": "user-123", "roles": ["admin"] }
// 4. Use refresh token rotation
class TokenService {
async refreshTokens(refreshToken: string) {
const stored = await this.tokenStore.get(refreshToken);
// Invalidate old refresh token
await this.tokenStore.delete(refreshToken);
// Issue new pair
return {
accessToken: this.generateAccessToken(stored.userId),
refreshToken: this.generateRefreshToken(stored.userId)
};
}
}
Protocol Comparison Summary
When to Use What
| Scenario | Recommended Protocol |
|---|---|
| Third-party API access | OAuth 2.0 |
| User login for web/mobile apps | OIDC |
| Enterprise SSO | SAML 2.0 or OIDC |
| Service-to-service | OAuth 2.0 Client Credentials |
| Simple internal APIs | API Keys or JWT |
| Social login | OIDC (via providers) |
| Legacy enterprise systems | SAML 2.0 |
Feature Comparison
| Feature | OAuth 2.0 | OIDC | SAML 2.0 |
|---|---|---|---|
| Primary Purpose | Authorization | Authentication | Authentication |
| Token Format | Opaque/JWT | JWT | XML |
| Transport | JSON/HTTP | JSON/HTTP | XML/HTTP |
| Mobile Support | ✅ Excellent | ✅ Excellent | ⚠️ Limited |
| SPA Support | ✅ (with PKCE) | ✅ (with PKCE) | ❌ |
| Enterprise Ready | ✅ | ✅ | ✅ |
| Complexity | Medium | Medium | High |
| Discovery | Manual | Standardized | Metadata XML |
Security Comparison
| Aspect | OAuth 2.0 | OIDC | SAML |
|---|---|---|---|
| Signature | Optional | Required (ID Token) | Required |
| Encryption | Optional | Optional | Optional |
| Replay Protection | State param | Nonce | NotOnOrAfter |
| CSRF Protection | State param | State + Nonce | RelayState |
Microservices Authentication Patterns
1. API Gateway Authentication
┌─────────┐ ┌─────────────┐ ┌──────────────┐
│ Client │────>│ API Gateway │────>│ Microservice │
└─────────┘ │ (Validates │ └──────────────┘
│ Token) │
└─────────────┘
// API Gateway validates token once
class APIGateway {
async handleRequest(req: Request) {
const token = req.headers.authorization?.split(' ')[1];
// Validate token
const user = await this.authService.validateToken(token);
// Forward with user context
return this.forwardToService(req, {
'X-User-Id': user.id,
'X-User-Roles': user.roles.join(',')
});
}
}
2. Service-to-Service Authentication
// Option A: Shared JWT validation
class ServiceA {
async callServiceB(data: any) {
// Use service account token
const serviceToken = await this.getServiceToken();
return fetch('https://service-b/api', {
headers: {
'Authorization': `Bearer ${serviceToken}`
},
body: JSON.stringify(data)
});
}
}
// Option B: mTLS (Mutual TLS)
const httpsAgent = new https.Agent({
cert: fs.readFileSync('client-cert.pem'),
key: fs.readFileSync('client-key.pem'),
ca: fs.readFileSync('ca-cert.pem')
});
3. Token Propagation
// Propagate user context through service chain
class OrderService {
async createOrder(req: Request, orderData: OrderDTO) {
const userToken = req.headers.authorization;
// Pass user's token to downstream services
const inventory = await this.inventoryService.reserve(
orderData.items,
{ headers: { 'Authorization': userToken } }
);
const payment = await this.paymentService.charge(
orderData.total,
{ headers: { 'Authorization': userToken } }
);
}
}
Common Security Vulnerabilities
1. Token Storage
// ❌ BAD: localStorage (XSS vulnerable)
localStorage.setItem('token', accessToken);
// ✅ GOOD: httpOnly cookie
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000
});
// ✅ GOOD: In-memory for SPAs (with refresh rotation)
class TokenManager {
private accessToken: string | null = null;
setToken(token: string) {
this.accessToken = token;
}
}
2. CSRF Protection
// Use state parameter in OAuth
const state = crypto.randomBytes(32).toString('hex');
session.oauthState = state;
const authUrl = `${AUTH_URL}?state=${state}&...`;
// Verify on callback
if (req.query.state !== session.oauthState) {
throw new Error('CSRF detected');
}
3. Token Leakage Prevention
// Use PKCE for public clients
// Don't expose tokens in URLs (use POST)
// Implement token binding
// Use short-lived access tokens
Duong Ngo
Full-Stack AI Developer with 12+ years of experience