Architecture Patterns You'll Learn
- • Domain-driven microservices design
- • API Gateway and service mesh patterns
- • Event-driven communication strategies
- • Data consistency and transaction management
- • Observability and monitoring at scale
- • Real banking app deployment architecture
Why Microservices for Banking?
When building the IDFC FIRST Bank mobile application that now serves over 10 million users with a 4.9-star rating, we needed an architecture that could handle massive scale, ensure regulatory compliance, and enable rapid feature development across multiple teams.
Microservices provided the solution: independent deployability, technology diversity, fault isolation, and the ability to scale specific services based on demand. This guide shares the patterns and practices that proved successful in production.
Core Architecture Principles
Domain Boundaries
- • User Management Service
- • Account Management Service
- • Transaction Processing Service
- • Notification Service
- • Audit & Compliance Service
Technical Boundaries
- • Independent databases
- • Separate deployment pipelines
- • Technology stack flexibility
- • Autonomous team ownership
- • Isolated failure domains
Service Design Patterns
Domain-Driven Service Structure
// User Management Service
// src/user-service/domain/User.ts
export class User {
constructor(
public readonly id: UserId,
public readonly email: Email,
public readonly profile: UserProfile,
private _status: UserStatus
) {}
activate(): void {
if (this._status === UserStatus.SUSPENDED) {
throw new Error('Cannot activate suspended user');
}
this._status = UserStatus.ACTIVE;
}
suspend(reason: string): void {
this._status = UserStatus.SUSPENDED;
// Emit domain event
DomainEvents.raise(new UserSuspendedEvent(this.id, reason));
}
get status(): UserStatus {
return this._status;
}
}
// src/user-service/application/UserService.ts
export class UserService {
constructor(
private userRepository: UserRepository,
private eventBus: EventBus,
private logger: Logger
) {}
async createUser(command: CreateUserCommand): Promise<User> {
const existingUser = await this.userRepository.findByEmail(command.email);
if (existingUser) {
throw new UserAlreadyExistsError(command.email);
}
const user = new User(
UserId.generate(),
new Email(command.email),
new UserProfile(command.firstName, command.lastName),
UserStatus.PENDING_VERIFICATION
);
await this.userRepository.save(user);
await this.eventBus.publish(new UserCreatedEvent(
user.id,
user.email.value,
user.profile
));
this.logger.info('User created', { userId: user.id.value });
return user;
}
async activateUser(userId: string): Promise<void> {
const user = await this.userRepository.findById(new UserId(userId));
if (!user) {
throw new UserNotFoundError(userId);
}
user.activate();
await this.userRepository.save(user);
await this.eventBus.publish(new UserActivatedEvent(user.id));
}
}
// src/user-service/infrastructure/api/UserController.ts
@Controller('/api/v1/users')
export class UserController {
constructor(
private userService: UserService,
private queryService: UserQueryService
) {}
@Post('/')
@UsePipes(new ValidationPipe())
async createUser(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
try {
const command = new CreateUserCommand(
dto.email,
dto.firstName,
dto.lastName
);
const user = await this.userService.createUser(command);
return UserResponseDto.fromDomain(user);
} catch (error) {
if (error instanceof UserAlreadyExistsError) {
throw new ConflictException('User already exists');
}
throw new InternalServerErrorException('Failed to create user');
}
}
@Put('/:id/activate')
async activateUser(@Param('id') id: string): Promise<void> {
await this.userService.activateUser(id);
}
@Get('/:id')
async getUser(@Param('id') id: string): Promise<UserResponseDto> {
const user = await this.queryService.getUserById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
}API Gateway Pattern
// API Gateway Service
// src/api-gateway/middleware/auth.ts
export class AuthMiddleware {
constructor(private jwtService: JwtService) {}
async authenticate(req: Request, res: Response, next: NextFunction) {
try {
const token = this.extractToken(req);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const payload = await this.jwtService.verify(token);
req.user = payload;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
private extractToken(req: Request): string | null {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return null;
}
}
// src/api-gateway/routes/index.ts
export class ApiGateway {
private app: Express;
private serviceRegistry: ServiceRegistry;
constructor() {
this.app = express();
this.serviceRegistry = new ServiceRegistry();
this.setupMiddleware();
this.setupRoutes();
}
private setupMiddleware(): void {
this.app.use(express.json());
this.app.use(cors());
this.app.use(helmet());
this.app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
}));
}
private setupRoutes(): void {
// User service routes
this.app.use('/api/v1/users',
new AuthMiddleware(this.jwtService).authenticate,
this.createProxy('user-service')
);
// Account service routes
this.app.use('/api/v1/accounts',
new AuthMiddleware(this.jwtService).authenticate,
new RoleMiddleware(['ACCOUNT_HOLDER']).authorize,
this.createProxy('account-service')
);
// Transaction service routes
this.app.use('/api/v1/transactions',
new AuthMiddleware(this.jwtService).authenticate,
new RoleMiddleware(['ACCOUNT_HOLDER']).authorize,
this.createProxy('transaction-service')
);
}
private createProxy(serviceName: string): RequestHandler {
return createProxyMiddleware({
target: this.serviceRegistry.getServiceUrl(serviceName),
changeOrigin: true,
pathRewrite: {
'^/api/v1': '/api/v1'
},
onError: (err, req, res) => {
console.error(`Proxy error for ${serviceName}:`, err);
res.status(503).json({ error: 'Service temporarily unavailable' });
},
onProxyReq: (proxyReq, req) => {
// Add user context to downstream services
if (req.user) {
proxyReq.setHeader('X-User-ID', req.user.id);
proxyReq.setHeader('X-User-Roles', JSON.stringify(req.user.roles));
}
}
});
}
}
// Service Discovery
export class ServiceRegistry {
private services: Map<string, ServiceInstance[]> = new Map();
registerService(name: string, instance: ServiceInstance): void {
const instances = this.services.get(name) || [];
instances.push(instance);
this.services.set(name, instances);
}
getServiceUrl(name: string): string {
const instances = this.services.get(name);
if (!instances || instances.length === 0) {
throw new Error(`No instances available for service: ${name}`);
}
// Simple round-robin load balancing
const instance = instances[Math.floor(Math.random() * instances.length)];
return `http://${instance.host}:${instance.port}`;
}
}Event-Driven Communication
Event Bus Implementation
// Event infrastructure
// src/shared/events/EventBus.ts
export interface Event {
id: string;
type: string;
timestamp: Date;
version: number;
payload: any;
}
export interface EventHandler<T extends Event> {
handle(event: T): Promise<void>;
}
export class EventBus {
private handlers: Map<string, EventHandler<any>[]> = new Map();
private publisher: EventPublisher;
constructor(publisher: EventPublisher) {
this.publisher = publisher;
}
subscribe<T extends Event>(
eventType: string,
handler: EventHandler<T>
): void {
const handlers = this.handlers.get(eventType) || [];
handlers.push(handler);
this.handlers.set(eventType, handlers);
}
async publish(event: Event): Promise<void> {
// Publish to external message broker
await this.publisher.publish(event);
// Handle local subscriptions
const handlers = this.handlers.get(event.type) || [];
await Promise.all(
handlers.map(handler =>
handler.handle(event).catch(error => {
console.error(`Error handling event ${event.type}:`, error);
})
)
);
}
}
// Domain Events
export class UserCreatedEvent implements Event {
id: string;
type = 'UserCreated';
timestamp: Date;
version = 1;
constructor(
public payload: {
userId: string;
email: string;
profile: UserProfile;
}
) {
this.id = uuidv4();
this.timestamp = new Date();
}
}
export class TransactionProcessedEvent implements Event {
id: string;
type = 'TransactionProcessed';
timestamp: Date;
version = 1;
constructor(
public payload: {
transactionId: string;
fromAccountId: string;
toAccountId: string;
amount: number;
currency: string;
status: 'success' | 'failed';
}
) {
this.id = uuidv4();
this.timestamp = new Date();
}
}
// Event Handlers in different services
// Notification Service
export class UserCreatedHandler implements EventHandler<UserCreatedEvent> {
constructor(
private emailService: EmailService,
private smsService: SmsService
) {}
async handle(event: UserCreatedEvent): Promise<void> {
const { email, profile } = event.payload;
try {
await Promise.all([
this.emailService.sendWelcomeEmail(email, profile.firstName),
this.smsService.sendVerificationCode(profile.phoneNumber)
]);
console.log(`Welcome notifications sent for user: ${event.payload.userId}`);
} catch (error) {
console.error('Failed to send welcome notifications:', error);
// Could implement retry logic or dead letter queue
}
}
}
// Account Service
export class TransactionProcessedHandler implements EventHandler<TransactionProcessedEvent> {
constructor(private accountService: AccountService) {}
async handle(event: TransactionProcessedEvent): Promise<void> {
const { fromAccountId, toAccountId, amount, status } = event.payload;
if (status === 'success') {
await Promise.all([
this.accountService.updateBalance(fromAccountId, -amount),
this.accountService.updateBalance(toAccountId, amount)
]);
}
console.log(`Account balances updated for transaction: ${event.payload.transactionId}`);
}
}Saga Pattern for Distributed Transactions
// Money Transfer Saga
export class MoneyTransferSaga {
constructor(
private accountService: AccountService,
private transactionService: TransactionService,
private notificationService: NotificationService,
private eventBus: EventBus
) {}
async execute(command: TransferMoneyCommand): Promise<void> {
const sagaId = uuidv4();
let compensationActions: (() => Promise<void>)[] = [];
try {
// Step 1: Validate accounts
const [fromAccount, toAccount] = await Promise.all([
this.accountService.getAccount(command.fromAccountId),
this.accountService.getAccount(command.toAccountId)
]);
if (!fromAccount || !toAccount) {
throw new Error('Invalid account');
}
if (fromAccount.balance < command.amount) {
throw new Error('Insufficient funds');
}
// Step 2: Reserve funds (debit from account)
await this.accountService.reserveFunds(
command.fromAccountId,
command.amount
);
compensationActions.push(() =>
this.accountService.releaseFunds(command.fromAccountId, command.amount)
);
// Step 3: Create transaction record
const transaction = await this.transactionService.createTransaction({
fromAccountId: command.fromAccountId,
toAccountId: command.toAccountId,
amount: command.amount,
currency: command.currency,
description: command.description
});
compensationActions.push(() =>
this.transactionService.cancelTransaction(transaction.id)
);
// Step 4: Process transfer
await this.accountService.processTransfer(
command.fromAccountId,
command.toAccountId,
command.amount
);
// Step 5: Update transaction status
await this.transactionService.markAsCompleted(transaction.id);
// Step 6: Send notifications
await this.notificationService.sendTransferNotification(
fromAccount.userId,
toAccount.userId,
command.amount,
command.currency
);
// Success - publish completion event
await this.eventBus.publish(new TransferCompletedEvent({
sagaId,
transactionId: transaction.id,
fromAccountId: command.fromAccountId,
toAccountId: command.toAccountId,
amount: command.amount
}));
} catch (error) {
console.error(`Transfer saga failed (ID: ${sagaId}):`, error);
// Execute compensation actions in reverse order
for (const action of compensationActions.reverse()) {
try {
await action();
} catch (compensationError) {
console.error('Compensation action failed:', compensationError);
// Could implement manual intervention alerts
}
}
await this.eventBus.publish(new TransferFailedEvent({
sagaId,
error: error.message,
fromAccountId: command.fromAccountId,
toAccountId: command.toAccountId,
amount: command.amount
}));
throw error;
}
}
}
// Orchestration vs Choreography
export class TransferOrchestrator {
async orchestrateTransfer(command: TransferMoneyCommand): Promise<void> {
const saga = new MoneyTransferSaga(
this.accountService,
this.transactionService,
this.notificationService,
this.eventBus
);
await saga.execute(command);
}
}
// Alternative: Choreography-based approach
export class AccountService {
async processTransfer(command: TransferMoneyCommand): Promise<void> {
// Process the transfer
const result = await this.performTransfer(command);
if (result.success) {
// Let other services know via events
await this.eventBus.publish(new TransferProcessedEvent(result));
} else {
await this.eventBus.publish(new TransferFailedEvent(result));
}
}
}Data Management Patterns
Database per Service
// User Service Database Schema
// src/user-service/infrastructure/database/UserRepository.ts
export class UserRepository {
constructor(private db: Database) {}
async save(user: User): Promise<void> {
const userData = {
id: user.id.value,
email: user.email.value,
first_name: user.profile.firstName,
last_name: user.profile.lastName,
status: user.status,
created_at: new Date(),
updated_at: new Date()
};
await this.db.query(
`INSERT INTO users (id, email, first_name, last_name, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id)
DO UPDATE SET
email = EXCLUDED.email,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at`,
[userData.id, userData.email, userData.first_name, userData.last_name,
userData.status, userData.created_at, userData.updated_at]
);
}
async findById(id: UserId): Promise<User | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE id = $1',
[id.value]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return new User(
new UserId(row.id),
new Email(row.email),
new UserProfile(row.first_name, row.last_name),
row.status
);
}
}
// Account Service Database Schema
export class AccountRepository {
constructor(private db: Database) {}
async findByUserId(userId: string): Promise<Account[]> {
const result = await this.db.query(
`SELECT a.*, at.name as account_type_name
FROM accounts a
JOIN account_types at ON a.account_type_id = at.id
WHERE a.user_id = $1 AND a.status = 'ACTIVE'`,
[userId]
);
return result.rows.map(row => this.mapToAccount(row));
}
async updateBalance(
accountId: string,
amount: number,
transactionId: string
): Promise<void> {
const client = await this.db.getClient();
try {
await client.query('BEGIN');
// Check current balance
const balanceResult = await client.query(
'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE',
[accountId]
);
const currentBalance = balanceResult.rows[0]?.balance || 0;
const newBalance = currentBalance + amount;
if (newBalance < 0) {
throw new InsufficientFundsError();
}
// Update balance
await client.query(
'UPDATE accounts SET balance = $1, updated_at = NOW() WHERE id = $2',
[newBalance, accountId]
);
// Record transaction
await client.query(
`INSERT INTO account_transactions
(account_id, transaction_id, amount, balance_after, created_at)
VALUES ($1, $2, $3, $4, NOW())`,
[accountId, transactionId, amount, newBalance]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
// CQRS Pattern for Complex Queries
export class TransactionQueryService {
constructor(
private readDb: ReadDatabase,
private eventStore: EventStore
) {}
async getUserTransactionHistory(
userId: string,
filters: TransactionFilters
): Promise<TransactionHistory> {
// Use read-optimized database/view
const query = `
SELECT
t.id,
t.amount,
t.currency,
t.description,
t.created_at,
t.status,
fa.account_number as from_account,
ta.account_number as to_account,
u.first_name as beneficiary_name
FROM transaction_view t
LEFT JOIN accounts fa ON t.from_account_id = fa.id
LEFT JOIN accounts ta ON t.to_account_id = ta.id
LEFT JOIN users u ON ta.user_id = u.id
WHERE (fa.user_id = $1 OR ta.user_id = $1)
AND t.created_at >= $2
AND t.created_at <= $3
AND ($4::text IS NULL OR t.status = $4)
ORDER BY t.created_at DESC
LIMIT $5 OFFSET $6
`;
const result = await this.readDb.query(query, [
userId,
filters.fromDate,
filters.toDate,
filters.status,
filters.limit,
filters.offset
]);
return {
transactions: result.rows,
total: await this.getTransactionCount(userId, filters),
hasNext: result.rows.length === filters.limit
};
}
}Observability & Monitoring
Distributed Tracing
// Tracing middleware
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
export class TracingMiddleware {
constructor(private serviceName: string) {}
middleware() {
return (req: Request, res: Response, next: NextFunction) => {
const tracer = trace.getTracer(this.serviceName);
const span = tracer.startSpan(`${req.method} ${req.path}`, {
attributes: {
'http.method': req.method,
'http.url': req.url,
'http.route': req.path,
'user.id': req.user?.id,
}
});
context.with(trace.setSpan(context.active(), span), () => {
span.addEvent('request.start');
res.on('finish', () => {
span.setAttributes({
'http.status_code': res.statusCode,
'http.response.size': res.get('content-length'),
});
if (res.statusCode >= 400) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: `HTTP ${res.statusCode}`
});
}
span.addEvent('request.finish');
span.end();
});
next();
});
};
}
}
// Service instrumentation
export class UserService {
constructor(
private userRepository: UserRepository,
private eventBus: EventBus
) {}
async createUser(command: CreateUserCommand): Promise<User> {
const tracer = trace.getTracer('user-service');
return tracer.startActiveSpan('UserService.createUser', async (span) => {
try {
span.setAttributes({
'user.email': command.email,
'operation': 'create_user'
});
// Check if user exists
const existingUser = await tracer.startActiveSpan(
'UserRepository.findByEmail',
async (childSpan) => {
childSpan.setAttributes({ 'user.email': command.email });
const result = await this.userRepository.findByEmail(command.email);
childSpan.setAttributes({ 'user.exists': !!result });
return result;
}
);
if (existingUser) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: 'User already exists'
});
throw new UserAlreadyExistsError(command.email);
}
const user = new User(/* ... */);
await tracer.startActiveSpan('UserRepository.save', async (childSpan) => {
childSpan.setAttributes({ 'user.id': user.id.value });
await this.userRepository.save(user);
});
await tracer.startActiveSpan('EventBus.publish', async (childSpan) => {
childSpan.setAttributes({
'event.type': 'UserCreated',
'user.id': user.id.value
});
await this.eventBus.publish(new UserCreatedEvent(user));
});
span.setAttributes({ 'user.id': user.id.value });
return user;
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
});
throw error;
}
});
}
}
// Metrics collection
export class MetricsCollector {
private registry = new PrometheusRegistry();
private httpDuration: Histogram;
private httpRequests: Counter;
constructor() {
this.httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.1, 0.5, 1, 2, 5]
});
this.httpRequests = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code']
});
this.registry.registerMetric(this.httpDuration);
this.registry.registerMetric(this.httpRequests);
}
collectHttpMetrics() {
return (req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const labels = {
method: req.method,
route: req.route?.path || req.path,
status_code: res.statusCode.toString()
};
this.httpDuration.observe(labels, duration);
this.httpRequests.inc(labels);
});
next();
};
}
getMetrics(): string {
return this.registry.metrics();
}
}Deployment Architecture
Container Orchestration
# docker-compose.yml for local development
version: '3.8'
services:
api-gateway:
build: ./api-gateway
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- USER_SERVICE_URL=http://user-service:3001
- ACCOUNT_SERVICE_URL=http://account-service:3002
depends_on:
- user-service
- account-service
user-service:
build: ./user-service
ports:
- "3001:3001"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://user:password@user-db:5432/users
- REDIS_URL=redis://redis:6379
depends_on:
- user-db
- redis
account-service:
build: ./account-service
ports:
- "3002:3002"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://account:password@account-db:5432/accounts
depends_on:
- account-db
user-db:
image: postgres:14
environment:
POSTGRES_DB: users
POSTGRES_USER: user
POSTGRES_PASSWORD: password
volumes:
- user_data:/var/lib/postgresql/data
account-db:
image: postgres:14
environment:
POSTGRES_DB: accounts
POSTGRES_USER: account
POSTGRES_PASSWORD: password
volumes:
- account_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
user_data:
account_data:
redis_data:
# Kubernetes deployment example
# k8s/user-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
labels:
app: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: banking-app/user-service:latest
ports:
- containerPort: 3001
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: user-service-secrets
key: database-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: user-service-secrets
key: jwt-secret
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3001
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
selector:
app: user-service
ports:
- protocol: TCP
port: 80
targetPort: 3001
type: ClusterIPProduction Lessons Learned
What Worked Well
- • Domain-driven service boundaries
- • Event-driven architecture for decoupling
- • Comprehensive monitoring and tracing
- • Database per service pattern
- • Circuit breakers for fault tolerance
- • Gradual rollout strategies
Challenges Faced
- • Distributed transaction complexity
- • Network latency between services
- • Data consistency challenges
- • Debugging across service boundaries
- • Service versioning and compatibility
- • Operational complexity increase
Conclusion
Microservices architecture enabled our banking application to scale to 10+ million users while maintaining high availability and security standards. The key to success was focusing on business domain boundaries, implementing robust communication patterns, and investing heavily in observability.
Start with a modular monolith, identify clear service boundaries, and gradually extract services as your team and requirements grow. Remember that microservices solve organizational and scaling problems but introduce distributed system complexity that must be managed carefully.
Ready to Build Scalable Microservices?
Need help designing microservices architecture for your application? I specialize in building production-ready distributed systems that scale.