ในบทความนี้
- 🐕 ทำไมต้องรู้ Architecture?
- 🏢 Monolith vs Microservices
- 📐 Design Patterns — MVC, MVVM, Clean Architecture
- 🌐 API Styles — REST vs GraphQL vs gRPC
- 📨 Event-Driven Architecture & Message Queues
- 🏗️ Communication Patterns in Distributed Systems
- 🌍 Real-World Architecture Examples
- 📋 Architecture Decision Cheat Sheet
- 🔑 Key Takeaways
In this post
- 🐕 Why Should You Care About Architecture?
- 🏢 Monolith vs Microservices
- 📐 Design Patterns — MVC, MVVM, Clean Architecture
- 🌐 API Styles — REST vs GraphQL vs gRPC
- 📨 Event-Driven Architecture & Message Queues
- 🏗️ Communication Patterns in Distributed Systems
- 🌍 Real-World Architecture Examples
- 📋 Architecture Decision Cheat Sheet
- 🔑 Key Takeaways
🐕 ทำไมต้องรู้ Architecture?
Architecture เป็นหัวใจสำคัญของการพัฒนาซอฟต์แวร์ที่หลายคนมองข้าม เพราะมันไม่ใช่แค่เรื่องของการเขียนโค้ดให้ทำงานได้ แต่เป็นการออกแบบโครงสร้างที่จะทำให้ระบบของเราสามารถ scale ได้ ดูแลรักษาได้ และพัฒนาต่อยอดได้ในระยะยาว
ความแตกต่างระหว่างโค้ดที่ดีกับ architecture ที่ดีนั้นเปรียบเสมือนความแตกต่างระหว่างการสร้างบ้านที่สวยงามกับการวางแปลนบ้านที่ดี บ้านที่สวยแต่แปลนแย่อาจพังได้ง่าย แต่บ้านที่แปลนดีแม้จะตกแต่งไม่สวยก็ยังแก้ไขและปรับปรุงได้
- 1 ไฟล์ 10,000 บรรทัด
- แก้จุดนึง → พังอีก 5 จุด
- Deploy ทีนึง → ลุ้นทั้งทีม
- Scale ไม่ได้ → server ล่มตอน peak
- Developer ใหม่ต้อง 3 เดือนกว่าจะเข้าใจ
- แต่ละส่วนแยกชัดเจน
- แก้จุดนึง → ไม่กระทบจุดอื่น
- Deploy ทีละส่วนได้
- Scale เฉพาะส่วนที่ต้องการ
- Developer ใหม่เข้าใจภายใน 1 สัปดาห์
Code ดี + Architecture แย่ = ระบบพัง
Code แย่ + Architecture ดี = แก้ได้ทีหลัง
🏢 Monolith vs Microservices
การเลือกระหว่าง Monolith กับ Microservices เป็นหนึ่งในคำถามที่ถูกถามบ่อยที่สุดในยุคนี้ หลายคนคิดว่า microservices เป็น "silver bullet" ที่จะแก้ปัญหาทุกอย่างได้ แต่ความจริงแล้วแต่ละแบบมี trade-offs ของตัวเอง และการเลือกใช้ต้องดูจากบริบทของ project และทีมงาน
บริษัทส่วนใหญ่จะเริ่มต้นด้วย monolith เพราะเป็นแบบที่เข้าใจง่าย พัฒนาเร็ว และ deploy ไม่ซับซ้อน แล้วค่อยแยกเป็น microservices เมื่อทีมและระบบใหญ่ขึ้น
Monolith — ทุกอย่างอยู่ก้อนเดียว
Monolith คือสถาปัตยกรรมที่รวม feature ทั้งหมดของแอปไว้ใน codebase เดียว deploy ทีเดียว run ใน process เดียว ใช้ database ร่วมกัน เหมาะกับ startup หรือทีมเล็กที่ต้องการ ship feature เร็วๆ
- Deploy ทีเดียว ทั้งก้อน
- Scale ทีเดียว ทั้งก้อน
- พังทีเดียว... ก็ทั้งก้อน 💀
- แต่ละ service: Deploy อิสระ
- Scale อิสระ
- DB แยก (Database per service)
- ภาษา/tech stack อิสระ
- ทีมดูแลอิสระ
Comparison Table
| Monolith | Microservices | |
|---|---|---|
| Complexity | ⭐ Simple | ⭐⭐⭐ Complex |
| Development | ⚡ Fast start | 🐢 Slower setup |
| Deployment | All or nothing | Independent |
| Scaling | Scale everything | Scale per service |
| Tech stack | One stack | Mix & match |
| Data consistency | ✅ Easy (1 DB) | ⚠️ Eventual |
| Debugging | ✅ Stack trace | ⚠️ Distributed |
| Team size | 1-15 devs | 10-100+ devs |
| Fault isolation | ❌ One bug = entire app down | ✅ Service fails others survive |
| Communication | Function calls (fast) | Network (HTTP, gRPC, queues) |
| DevOps needs | ⭐ Basic | ⭐⭐⭐ K8s, etc. |
| Cost | 💰 Lower | 💰💰💰 Higher |
💡 Start with Monolith → Extract to Microservices when you actually NEED to
"Don't start with microservices unless you have a very good reason." — Martin Fowler
Microservices เป็นการแบ่งแอปพลิเคชันออกเป็น service เล็กๆ หลายตัวที่ทำงานแยกจากกัน แต่ละ service มีหน้าที่เฉพาะ มี database ของตัวเอง และสื่อสารกันผ่าน network (HTTP, gRPC, message queues)
ข้อดีคือแต่ละ service scale ได้อิสระ ใช้ tech stack ต่างกันได้ ทีมทำงานแยกกัน และถ้า service หนึ่งล่มก็ไม่กระทบ service อื่น แต่ข้อเสียคือความซับซ้อนในการ deploy การ debug และ network latency
Modular Monolith — Best of Both Worlds
Modular Monolith เป็น compromise ที่ดีระหว่าง simplicity ของ monolith กับ modularity ของ microservices โดยแบ่ง code เป็น module ที่มีขอบเขตชัดเจน แต่ยัง deploy เป็นก้อนเดียว
แต่ละ module จะมี API interface ของตัวเอง ไม่เข้าถึง database ของ module อื่นโดยตรง และสามารถแยกออกเป็น microservice ได้ง่ายในอนาคต นี่คือสิ่งที่ Martin Fowler แนะนำว่า "เริ่มด้วย monolith แล้วค่อยแยก"
(NOT direct DB access)
(but each module owns its tables)
- ✅ Simple deployment (1 unit)
- ✅ Clear module boundaries
- ✅ Easy to extract to microservice later
- ✅ No network overhead between modules
- ✅ Single DB = ACID transactions
📐 Design Patterns — MVC, MVVM, Clean Architecture
Design patterns เป็นแนวทางในการจัดระเบียบ code ให้มีโครงสร้างที่ชัดเจน แยกหน้าที่กันได้ดี และดูแลรักษาได้ง่าย แต่ละ pattern จะเหมาะกับ use case ที่แตกต่างกัน และมีระดับความซับซ้อนที่แตกต่างกัน
การเลือก design pattern ควรคำนึงถึงขนาดของโปรเจกต์ ความซับซ้อนของ business logic และขนาดของทีม อย่าเอา Clean Architecture มาใช้กับโปรเจกต์ CRUD เล็กๆ เพราะจะได้แต่ complexity เปล่าๆ
MVC (Model-View-Controller)
MVC เป็น pattern พื้นฐานที่แยกแอปพลิเคชันเป็น 3 ส่วน: Model (ข้อมูลและ business logic), View (การแสดงผล), และ Controller (จัดการ user input และควบคุมการทำงาน)
เหมาะกับ web application ที่ render หน้าเว็บฝั่ง server เช่น Rails, Django, Laravel หรือ Express.js ที่ใช้ template engine อย่าง EJS แต่สำหรับ API จะเป็นแค่ Controller กับ Model เพราะไม่มี View (ส่ง JSON กลับไป)
(Template)
HTML/EJS
(Logic)
Routes + Handlers
(Data + Business Logic)
Best for: Server-rendered (SSR) web apps
// ═══════════════════════════════
// MVC Example — Express.js
// ═══════════════════════════════
// Model — Database interaction
// models/Order.ts
class OrderModel {
static async create(data: OrderInput): Promise<Order> {
const result = await db.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[data.userId, data.total, 'pending']
);
return result.rows[0];
}
static async findByUser(userId: string): Promise<Order[]> {
return db.query('SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC', [userId]);
}
}
// Controller — Business logic
// controllers/orderController.ts
class OrderController {
static async createOrder(req: Request, res: Response) {
try {
const { items } = req.body;
// Business logic
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const order = await OrderModel.create({ userId: req.user.id, total });
// Respond
res.status(201).json({ order });
} catch (err) {
res.status(500).json({ error: 'Failed to create order' });
}
}
}
// View — Routes (API = JSON response)
// routes/orders.ts
router.post('/orders', authenticate, OrderController.createOrder);
router.get('/orders', authenticate, OrderController.listOrders);
Layered Architecture — Controller → Service → Repository
Layered Architecture หรือ N-Tier Architecture เป็นการแบ่ง application เป็น layer ที่มีหน้าที่ชัดเจน แต่ละ layer จะเรียกใช้แค่ layer ที่อยู่ข้างล่าง ไม่ข้าม layer และไม่เรียกกลับขึ้นไป
Pattern นี้เหมาะกับ business application ที่มี business logic ซับซ้อน เพราะแยก concerns ได้ชัดเจน ทำให้ test ได้ง่าย แก้ไขได้ง่าย และทีมงานแยกงานกันได้ดี แต่ถ้า logic ไม่ซับซ้อนอาจจะ overkill
→ HTTP requests, validation, response format
→ Business rules, orchestration, validation
→ Database queries, ORM, external APIs
Controller → Service ✅
Controller → Repository ❌ (ข้าม layer)
Service → Controller ❌ (ย้อนกลับ)
// ═══════════════════════════════
// Layered Architecture Example
// ═══════════════════════════════
// Repository Layer — Data access only
// repositories/orderRepository.ts
class OrderRepository {
async create(data: CreateOrderDTO): Promise<Order> {
return prisma.order.create({ data });
}
async findById(id: string): Promise<Order | null> {
return prisma.order.findUnique({
where: { id },
include: { items: true, user: true }
});
}
async findByUser(userId: string): Promise<Order[]> {
return prisma.order.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
});
}
}
// Service Layer — Business logic
// services/orderService.ts
class OrderService {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository,
private paymentService: PaymentService,
private notificationService: NotificationService,
) {}
async createOrder(userId: string, items: CartItem[]): Promise<Order> {
// 1. Validate stock
for (const item of items) {
const product = await this.productRepo.findById(item.productId);
if (!product) throw new NotFoundError(`Product ${item.productId} not found`);
if (product.stock < item.quantity) throw new ValidationError(`Insufficient stock for ${product.name}`);
}
// 2. Calculate total
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 3. Create order
const order = await this.orderRepo.create({
userId,
total,
status: 'pending',
items: items.map(i => ({
productId: i.productId,
quantity: i.quantity,
price: i.price,
})),
});
// 4. Reserve stock
for (const item of items) {
await this.productRepo.decrementStock(item.productId, item.quantity);
}
// 5. Send notification
await this.notificationService.sendOrderConfirmation(userId, order);
return order;
}
}
// Controller Layer — HTTP handling
// controllers/orderController.ts
class OrderController {
constructor(private orderService: OrderService) {}
createOrder = async (req: Request, res: Response) => {
try {
const { items } = req.body;
// Input validation only
if (!items?.length) {
return res.status(400).json({ error: 'Items required' });
}
// Delegate to service
const order = await this.orderService.createOrder(req.user.id, items);
res.status(201).json({ data: order });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
res.status(500).json({ error: 'Internal server error' });
}
};
}
Clean Architecture / Hexagonal Architecture
Clean Architecture (หรือ Hexagonal Architecture) เป็น pattern ที่เน้นการแยก business logic ออกจาก external dependencies เช่น database, web framework, external APIs โดยใช้หลักการ "Dependency Inversion"
หลักสำคัญคือ "Dependency Rule" - dependencies ต้องชี้เข้าข้างในเท่านั้น inner layers ไม่รู้จัก outer layers เลย ทำให้ business logic independent จาก framework และ database ทดสอบได้ง่าย แต่ code จะเยอะขึ้นและซับซ้อนกว่า
เหมาะกับโปรเจกต์ขนาดใหญ่ที่มี business rules ซับซ้อน ต้องการ testability สูง หรือต้องการเปลี่ยน technology stack บ่อย แต่ถ้าทำ CRUD ธรรมดาอาจจะเป็น overkill
Express, PostgreSQL, Redis, AWS S3
Controllers, Presenters, Gateways
Entities
Domain Models
- dependencies point INWARD only
- Inner layers know NOTHING about outer layers
- Use interfaces/ports at boundaries
🌐 API Styles — REST vs GraphQL vs gRPC
การเลือก API style เป็นการตัดสินใจสำคัญที่จะกระทบกับการพัฒนา frontend, mobile app, และ service-to-service communication แต่ละ style มี strengths และ use cases ที่แตกต่างกัน และไม่จำเป็นต้องเลือกแค่อันเดียว
หลายบริษัทใช้หลาย style พร้อมกัน เช่น REST สำหรับ public API, GraphQL สำหรับ mobile app, และ gRPC สำหรับ internal services การเข้าใจข้อดีข้อเสียของแต่ละแบบจะช่วยให้เลือกได้ถูกต้อง
REST — Resource-based
REST (Representational State Transfer) เป็น architectural style ที่ใช้ HTTP methods (GET, POST, PUT, DELETE) กับ URLs เพื่อจัดการ resources เป็น standard ที่เข้าใจง่าย มีเครื่องมือสนับสนุนเยอะ และ cache ได้ดีผ่าน HTTP
แต่ REST มีปัญหาในเรื่อง over-fetching (ได้ข้อมูลมาเกินความต้องการ), under-fetching (ต้อง call หลาย endpoints), และ N+1 problem ที่เกิดจากการ fetch related data แยกกัน
- Over-fetching — ขอ user แต่ได้ทุก field กลับมา
- Under-fetching — ต้อง call หลาย endpoints
- N+1 problem — list ที่ต้อง fetch related data
GraphQL — Query-based
GraphQL แก้ปัญหาของ REST โดยให้ client สามารถระบุได้ว่าต้องการข้อมูลอะไรบ้าง ผ่าน single endpoint เพียงแค่ POST /graphql เดียว client เขียน query บอกว่าต้องการ fields ไหนบ้าง server ก็ส่งแค่ส่วนนั้นกลับไป
ข้อดีคือแก้ปัญหา over/under-fetching ได้ลด API calls ลง strongly typed schema และมี introspection สำหรับ development tools แต่ข้อเสียคือ caching ยาก complexity สูงกว่า และต้องเรียนรู้ GraphQL query language
Client บอกว่าต้องการ data อะไร
user(id: 123) {
name
avatar
posts(limit: 5) {
title
likes
}
}
}
"data": {
"user": {
"name": "Alice",
"avatar": "url...",
"posts": [...]
}
}
}
# ═══════════════════════════════
# GraphQL Schema Definition
# ═══════════════════════════════
type User {
id: ID!
name: String!
email: String!
avatar: String
posts(limit: Int = 10, offset: Int = 0): [Post!]!
followers: Int!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
likes: Int!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(status: PostStatus, limit: Int): [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
# ═══════════════════════════════
# Client Queries
# ═══════════════════════════════
# Dashboard — 1 request แทน 4 requests
query Dashboard {
me: user(id: "123") {
name
avatar
followers
}
recentPosts: posts(limit: 5) {
title
likes
createdAt
}
}
# Mutation
mutation CreatePost {
createPost(input: {
title: "GraphQL is Awesome"
content: "Here's why..."
}) {
id
title
createdAt
}
}
gRPC — Binary Protocol (Service-to-Service)
gRPC เป็น high-performance RPC framework ที่ใช้ Protocol Buffers (protobuf) เป็น serialization format แทน JSON ทำให้เร็วกว่ามาก strongly typed จาก proto definition และสนับสนุน streaming สำหรับ real-time communication
เหมาะมากสำหรับ service-to-service communication ใน microservices เพราะ performance ดี type safety และ code generation แต่ไม่เหมาะกับ browser เพราะต้องใช้ grpc-web proxy และ learning curve สูงกว่า REST
- Binary protocol (Protocol Buffers) → 10x faster
- Strongly typed → contract-first development
- Streaming support → real-time data
- Code generation → auto-generate client/server
(Node.js)
(binary)
(Go)
(text, slow)
// ═══════════════════════════════
// user.proto — Protocol Buffer Definition
// ═══════════════════════════════
syntax = "proto3";
package user;
service UserService {
// Unary — request/response เดียว
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
// Server streaming — server ส่ง stream กลับ
rpc ListUsers (ListUsersRequest) returns (stream User);
// Bidirectional streaming — ทั้ง 2 ฝั่ง stream
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message User {
string id = 1;
string name = 2;
string email = 3;
UserRole role = 4;
int64 created_at = 5;
}
enum UserRole {
MEMBER = 0;
EDITOR = 1;
ADMIN = 2;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
string password = 3;
}
REST vs GraphQL vs gRPC — เลือกยังไง?
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Format | JSON (text) | JSON (text) | Protobuf (binary) |
| Transport | HTTP/1.1 | HTTP/1.1 | HTTP/2 |
| Performance | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| Type Safety | ❌ Manual | ✅ Schema | ✅ Proto |
| Browser Support | ✅ Native | ✅ Library | ⚠️ grpc-web |
| Over/Under fetch | ❌ Common | ✅ Exact | ✅ Exact |
| Caching | ✅ HTTP cache | ⚠️ Complex | ❌ Hard |
| Learning Curve | ⭐ Easy | ⭐⭐ Medium | ⭐⭐⭐ Hard |
| Best For | Public APIs, CRUD, simple | Mobile/SPA, complex UI | Microservice internal |
💡 Common patterns:
├── Public API → REST (simple, cacheable, everyone knows it)
├── Frontend (SPA/Mobile) → GraphQL (flexible queries)
├── Service-to-service → gRPC (fast, typed, streaming)
└── Mix! REST for public + gRPC for internal is common
📨 Event-Driven Architecture & Message Queues
Event-Driven Architecture เป็นการออกแบบระบบให้ทำงานแบบ asynchronous ผ่าน events และ message queues แทนการเรียก API แบบ synchronous ทำให้ระบบมี resilience สูงขึ้น scale ได้ดีกว่า และไม่ล่มทั้งระบบเมื่อ service ใดบาง service หนึ่งมีปัญหา
ประโยชน์หลักคือ loose coupling ระหว่าง services, fault tolerance เพราะ message queues จะ buffer messages ไว้, และ scalability เพราะแต่ละ service สามารถ process messages ในอัตราของตัวเองได้
Synchronous vs Asynchronous
ให้ดูตัวอย่างการสั่งซื้อสินค้า ในแบบ synchronous user กดสั่งซื้อแล้วต้องรอให้ระบบ create order, charge payment, update inventory, send email, send SMS, update analytics, และ generate invoice เสร็จทั้งหมดก่อนถึงจะได้ response
แต่ในแบบ asynchronous หลังจาก create order และ charge payment เสร็จแล้ว ระบบจะ publish event "order.created" ไปยัง message queue แล้ว respond กลับไปทันที ส่วนงานอื่นๆ อย่าง email, SMS, analytics จะทำใน background โดย consumers ที่ subscribe event นั้น
⚠️ ถ้า email server ล่ม → ทั้ง order fail!
├── Send email .............. async
├── Send SMS ................ async
├── Update analytics ........ async
└── Generate invoice ........ async
✅ Email ล่ม → retry อัตโนมัติ → order ไม่ fail
Message Queue Architecture
Message Queue เป็นตัวกลางที่เก็บ messages ไว้ชั่วคราว โดย Producer (service ที่ส่ง message) จะส่ง message เข้า queue, และ Consumer (service ที่รับ message) จะดึง message ออกมาประมวลผล
ข้อดีคือถ้า consumer ล่ม message ยังคงอยู่ใน queue ไม่สูญหาย และสามารถมี consumer หลายตัวมาแย่งงานกันได้ (competing consumers) ทำให้ scale ได้ดี Popular message queues ได้แก่ RabbitMQ, Apache Kafka, Redis Pub/Sub, AWS SQS
Event-Driven Example with RabbitMQ
// ═══════════════════════════════
// Producer — Order Service
// ═══════════════════════════════
import amqp from 'amqplib';
class OrderService {
private channel: amqp.Channel;
async connect() {
const conn = await amqp.connect('amqp://localhost');
this.channel = await conn.createChannel();
// Declare exchange (topic-based routing)
await this.channel.assertExchange('events', 'topic', { durable: true });
}
async createOrder(userId: string, items: CartItem[]) {
// 1. Business logic
const order = await this.orderRepo.create({ userId, items });
// 2. Publish event (async, non-blocking)
const event = {
type: 'order.created',
data: {
orderId: order.id,
userId,
items,
total: order.total,
timestamp: new Date().toISOString(),
},
};
this.channel.publish(
'events',
'order.created',
Buffer.from(JSON.stringify(event)),
{ persistent: true } // Survives broker restart
);
return order; // Return immediately!
}
}
// ═══════════════════════════════
// Consumer — Email Service
// ═══════════════════════════════
class EmailConsumer {
async start() {
const conn = await amqp.connect('amqp://localhost');
const channel = await conn.createChannel();
await channel.assertExchange('events', 'topic', { durable: true });
// Create queue for this consumer
const q = await channel.assertQueue('email-service', { durable: true });
// Bind to events we care about
await channel.bindQueue(q.queue, 'events', 'order.created');
await channel.bindQueue(q.queue, 'events', 'user.registered');
// Process messages
channel.prefetch(10); // Process 10 at a time
channel.consume(q.queue, async (msg) => {
if (!msg) return;
try {
const event = JSON.parse(msg.content.toString());
switch (event.type) {
case 'order.created':
await sendOrderConfirmation(event.data);
break;
case 'user.registered':
await sendWelcomeEmail(event.data);
break;
}
channel.ack(msg); // ✅ Message processed
} catch (err) {
channel.nack(msg, false, true); // ❌ Retry (requeue)
}
});
}
}
Apache Kafka — High-Throughput Event Streaming
Apache Kafka แตกต่างจาก message queues ทั่วไป เพราะเป็น "event log" มากกว่า "message queue" หมายความว่า events จะถูกเก็บไว้ในระยะเวลาหนึ่ง (เช่น 7 วัน) และ consumer สามารถ replay events เหล่านั้นได้
Kafka เหมาะกับ use cases ที่ต้องการ high throughput (100K+ messages/second), event sourcing, log aggregation, หรือ real-time analytics ส่วน RabbitMQ เหมาะกับ task queues, RPC, หรือ notification systems ที่ไม่ต้อง replay
(messages ถูกลบหลัง consume)
(events ถูกเก็บถาวร, replay ได้)
Partition 1: [e1][e2][e3]→
Partition 2: [e1][e2][e3][e4]→
Producers
(Order Service)
Consumer Groups
├── Email Service
├── Analytics Service
└── Inventory Service
- Retention: เก็บ events 7 วัน (configurable)
- Replay: consumer ย้อนอ่านได้
- Partitions: parallel processing
- Consumer Groups: load balancing
- Throughput: 100K+ messages/second
- Ordering: per-partition ordering guarantee
RabbitMQ vs Kafka — Quick Comparison
| RabbitMQ | Apache Kafka | |
|---|---|---|
| Model | Message Queue | Event Log |
| Messages | Deleted after consumption | Retained (7d+) replay possible |
| Throughput | ~10K msg/sec | ~100K+ msg/sec |
| Ordering | Per-queue | Per-partition |
| Routing | ✅ Flexible (exchange types) | Topic-based |
| Protocol | AMQP | Custom binary |
| Complexity | ⭐⭐ Medium | ⭐⭐⭐ Complex |
| Use Cases | Task queues, notifications, RPC | Event sourcing, analytics, logs, streaming |
💡 Small/medium apps → RabbitMQ (simpler)
High-throughput, event sourcing → Kafka
Cloud-native → AWS SQS/SNS (simplest managed option)
🏗️ Communication Patterns in Distributed Systems
เมื่อระบบแยกออกเป็น multiple services แล้ว จะเกิดความท้าทายในเรื่องการสื่อสาร การ manage failures การ handle transactions ข้าม services และการ observe system behavior
Communication patterns เหล่านี้เป็น proven solutions ที่ช่วยแก้ปัญหาเหล่านั้น แต่ละ pattern มีจุดประสงค์และ use cases ที่แตกต่างกัน การเลือกใช้ต้องดูจากความต้องการและความซับซ้อนที่ยอมรับได้
Gateway handles: routing, auth, rate limiting
Handles: mTLS, load balancing, observability
Each step has a compensating action
Write DB (normalized) ≠ Read DB (denormalized)
If Service B fails 5 times → stop calling
API Gateway เป็น single entry point ที่ handle routing, authentication, rate limiting, และ logging ช่วยลด complexity ฝั่ง client และ centralize cross-cutting concerns
Service Mesh เป็น infrastructure layer ที่ handle service-to-service communication ผ่าน sidecar proxy ให้ mTLS, load balancing, และ observability โดยไม่ต้องแก้ application code
Saga Pattern แก้ปัญหา distributed transactions โดยแบ่งเป็น steps และมี compensating action สำหรับแต่ละ step ถ้า step ใดล้มเหลว
CQRS แยก write model (normalized for consistency) กับ read model (denormalized for performance) เหมาะกับ systems ที่ read-heavy
Circuit Breaker ป้องกัน cascading failures โดยหยุดเรียก service ที่ล้มเหลว และค่อยๆ ลองใหม่เมื่อระบบฟื้นตัว
🌍 Real-World Architecture Examples
การดู architecture ตัวอย่างจากโลกจริงจะช่วยให้เข้าใจว่า patterns ต่างๆ ถูกนำไปใช้งานอย่างไรบ้าง และเหมาะกับ scale ขนาดไหน ที่นี่เราจะดู 2 แบบ: E-commerce ขนาดกลาง และ SaaS startup
E-commerce Platform (Medium Scale)
→ Static assets (images, CSS, JS)
Multiple instances
→ Auth, rate limiting, logging
SaaS Application (Simpler — Modular Monolith)
Auth | Users | Billing | Teams | API Modules
(cache)
(files)
(BullMQ)
E-commerce Architecture แสดงให้เห็น layered approach ที่มี CDN สำหรับ static assets, load balancer สำหรับ distribution, multiple web app instances สำหรับ scalability, API gateway สำหรับ API management, และ microservices แยกตาม domain
SaaS Architecture แสดงแนวทางสำหรับ startup ที่เลือกใช้ modular monolith เพื่อความ simple แต่ยังคง modularity ไว้สำหรับการแยกออกเป็น microservices ในอนาคต plus managed services เพื่อลด operational overhead
📋 Architecture Decision Cheat Sheet
การเลือก architecture ไม่มี "correct answer" ที่ universal เพราะทุกอย่างเป็น trade-offs ที่ต้องดูจากบริบทของ project, ทีม, timeline, budget, และ requirements แต่ guidelines เหล่านี้จะช่วยให้ตัดสินใจได้ง่ายขึ้น
🔑 Key Takeaways
หลังจากไปดู architecture patterns, API styles, design patterns, และ communication patterns ต่างๆ แล้ว มาสรุป lessons สำคัญที่จะช่วยให้เลือกใช้ได้ถูกต้องและไม่ over-engineer
- Start Monolith, Split Later → อย่าเริ่มด้วย microservices ถ้าไม่จำเป็น — modular monolith ดีพอสำหรับ 90% ของ startups
- MVC → Layered → Clean → เลือก complexity ให้เหมาะกับ project — MVC ก็ ship ได้!
- REST for public, GraphQL for SPA, gRPC for internal → ไม่ต้องเลือกอันเดียว ใช้ร่วมกันได้
- Event-driven = resilient → ใช้ message queues สำหรับงานที่ไม่ต้อง respond ทันที
- RabbitMQ vs Kafka → Task queue ใช้ RabbitMQ, event streaming ใช้ Kafka
- Saga ≠ distributed transaction → แต่ละ step มี compensating action
- CQRS → แยก write model กับ read model ถ้า read-heavy
- Circuit Breaker → ป้องกัน cascading failures ใน distributed systems
- API Gateway → Single entry point สำหรับ auth, rate limit, routing
- Architecture คือ trade-offs → ไม่มี "best" architecture — มีแค่ "right for this situation"
🐕 Why Should You Care About Architecture?
Architecture is the heart of software development, and it is the part most people skip. It is not about writing code that works — it is about designing a structure that lets the system scale, stay maintainable, and keep growing over the long term.
The difference between good code and good architecture is the difference between a beautifully decorated house and a well-drawn floor plan. A pretty house on a bad plan can collapse; a house on a good plan can be redecorated later even if it looks plain today.
- One file, 10,000 lines
- Fix one spot → break five others
- Every deploy → the whole team holds its breath
- Can't scale → the server dies at peak
- A new developer needs 3 months to understand it
- Every part has a clear boundary
- Fix one spot → nothing else moves
- Deploy one piece at a time
- Scale only the part that needs it
- A new developer understands it within a week
Good code + bad architecture = a system that breaks
Bad code + good architecture = something you can fix later
🏢 Monolith vs Microservices
Choosing between a monolith and microservices is one of the most frequently asked questions of this era. Many people treat microservices as a "silver bullet" that solves everything, but in reality each approach has its own trade-offs, and the choice depends on the context of the project and the team.
Most companies start with a monolith, because it is easy to understand, fast to develop, and simple to deploy — then split into microservices as the team and the system grow.
Monolith — Everything in One Piece
A monolith is an architecture that keeps every feature of the app in a single codebase, deployed as one unit, running in one process, sharing one database. It suits startups and small teams that need to ship features fast.
- Deploy once, the whole thing
- Scale once, the whole thing
- Break once... also the whole thing 💀
- Each service: deploy independently
- Scale independently
- Separate DBs (database per service)
- Independent language / tech stack
- An independent team per service
Comparison Table
| Monolith | Microservices | |
|---|---|---|
| Complexity | ⭐ Simple | ⭐⭐⭐ Complex |
| Development | ⚡ Fast start | 🐢 Slower setup |
| Deployment | All or nothing | Independent |
| Scaling | Scale everything | Scale per service |
| Tech stack | One stack | Mix & match |
| Data consistency | ✅ Easy (1 DB) | ⚠️ Eventual |
| Debugging | ✅ Stack trace | ⚠️ Distributed |
| Team size | 1-15 devs | 10-100+ devs |
| Fault isolation | ❌ One bug = entire app down | ✅ Service fails others survive |
| Communication | Function calls (fast) | Network (HTTP, gRPC, queues) |
| DevOps needs | ⭐ Basic | ⭐⭐⭐ K8s, etc. |
| Cost | 💰 Lower | 💰💰💰 Higher |
💡 Start with Monolith → Extract to Microservices when you actually NEED to
"Don't start with microservices unless you have a very good reason." — Martin Fowler
Microservices split an application into many small services that run separately. Each service has one job, owns its own database, and talks to the others over the network (HTTP, gRPC, message queues).
The upside is that each service scales independently, can use a different tech stack, is owned by a separate team, and one service falling over does not take the others with it. The downside is deployment complexity, harder debugging, and network latency.
Modular Monolith — Best of Both Worlds
The modular monolith is a good compromise between the simplicity of a monolith and the modularity of microservices: the code is split into modules with clear boundaries, but it still deploys as a single unit.
Each module exposes its own API interface, never reaches into another module's database directly, and can be extracted into a microservice easily later. This is exactly what Martin Fowler means by "start with a monolith, then split."
(NOT direct DB access)
(but each module owns its tables)
- ✅ Simple deployment (1 unit)
- ✅ Clear module boundaries
- ✅ Easy to extract to microservice later
- ✅ No network overhead between modules
- ✅ Single DB = ACID transactions
📐 Design Patterns — MVC, MVVM, Clean Architecture
Design patterns are ways of organizing code so that it has a clear structure, a clean separation of responsibilities, and stays maintainable. Each pattern suits a different use case and carries a different level of complexity.
Choose a design pattern by looking at the size of the project, the complexity of the business logic, and the size of the team. Do not drag Clean Architecture into a small CRUD project — all you will get is complexity for nothing.
MVC (Model-View-Controller)
MVC is the foundational pattern that splits an application into three parts: the Model (data and business logic), the View (presentation), and the Controller (handling user input and orchestrating the work).
It suits web applications that render pages on the server — Rails, Django, Laravel, or Express.js with a template engine such as EJS. For an API it collapses to just Controller and Model, because there is no View (you return JSON).
(Template)
HTML/EJS
(Logic)
Routes + Handlers
(Data + Business Logic)
Best for: Server-rendered (SSR) web apps
// ═══════════════════════════════
// MVC Example — Express.js
// ═══════════════════════════════
// Model — Database interaction
// models/Order.ts
class OrderModel {
static async create(data: OrderInput): Promise<Order> {
const result = await db.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[data.userId, data.total, 'pending']
);
return result.rows[0];
}
static async findByUser(userId: string): Promise<Order[]> {
return db.query('SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC', [userId]);
}
}
// Controller — Business logic
// controllers/orderController.ts
class OrderController {
static async createOrder(req: Request, res: Response) {
try {
const { items } = req.body;
// Business logic
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const order = await OrderModel.create({ userId: req.user.id, total });
// Respond
res.status(201).json({ order });
} catch (err) {
res.status(500).json({ error: 'Failed to create order' });
}
}
}
// View — Routes (API = JSON response)
// routes/orders.ts
router.post('/orders', authenticate, OrderController.createOrder);
router.get('/orders', authenticate, OrderController.listOrders);
Layered Architecture — Controller → Service → Repository
Layered architecture, or N-tier architecture, divides the application into layers with clear responsibilities. Each layer calls only the layer directly beneath it — never skipping a layer, never calling back upward.
This pattern suits business applications with complicated business logic, because the separation of concerns is explicit: it is easy to test, easy to change, and easy for a team to divide the work. If your logic is not complicated, it is overkill.
→ HTTP requests, validation, response format
→ Business rules, orchestration, validation
→ Database queries, ORM, external APIs
Controller → Service ✅
Controller → Repository ❌ (skips a layer)
Service → Controller ❌ (goes backwards)
// ═══════════════════════════════
// Layered Architecture Example
// ═══════════════════════════════
// Repository Layer — Data access only
// repositories/orderRepository.ts
class OrderRepository {
async create(data: CreateOrderDTO): Promise<Order> {
return prisma.order.create({ data });
}
async findById(id: string): Promise<Order | null> {
return prisma.order.findUnique({
where: { id },
include: { items: true, user: true }
});
}
async findByUser(userId: string): Promise<Order[]> {
return prisma.order.findMany({
where: { userId },
orderBy: { createdAt: 'desc' }
});
}
}
// Service Layer — Business logic
// services/orderService.ts
class OrderService {
constructor(
private orderRepo: OrderRepository,
private productRepo: ProductRepository,
private paymentService: PaymentService,
private notificationService: NotificationService,
) {}
async createOrder(userId: string, items: CartItem[]): Promise<Order> {
// 1. Validate stock
for (const item of items) {
const product = await this.productRepo.findById(item.productId);
if (!product) throw new NotFoundError(`Product ${item.productId} not found`);
if (product.stock < item.quantity) throw new ValidationError(`Insufficient stock for ${product.name}`);
}
// 2. Calculate total
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 3. Create order
const order = await this.orderRepo.create({
userId,
total,
status: 'pending',
items: items.map(i => ({
productId: i.productId,
quantity: i.quantity,
price: i.price,
})),
});
// 4. Reserve stock
for (const item of items) {
await this.productRepo.decrementStock(item.productId, item.quantity);
}
// 5. Send notification
await this.notificationService.sendOrderConfirmation(userId, order);
return order;
}
}
// Controller Layer — HTTP handling
// controllers/orderController.ts
class OrderController {
constructor(private orderService: OrderService) {}
createOrder = async (req: Request, res: Response) => {
try {
const { items } = req.body;
// Input validation only
if (!items?.length) {
return res.status(400).json({ error: 'Items required' });
}
// Delegate to service
const order = await this.orderService.createOrder(req.user.id, items);
res.status(201).json({ data: order });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
res.status(500).json({ error: 'Internal server error' });
}
};
}
Clean Architecture / Hexagonal Architecture
Clean Architecture (or Hexagonal Architecture) is a pattern built around separating business logic from external dependencies such as the database, the web framework, and external APIs, using the principle of "Dependency Inversion".
The core idea is the "Dependency Rule": dependencies may point inward only, and inner layers know nothing at all about outer layers. That makes business logic independent of the framework and the database, and easy to test — at the cost of more code and more complexity.
It suits large projects with complicated business rules, projects that need high testability, or projects that change technology stack often. For ordinary CRUD it is overkill.
Express, PostgreSQL, Redis, AWS S3
Controllers, Presenters, Gateways
Entities
Domain Models
- dependencies point INWARD only
- Inner layers know NOTHING about outer layers
- Use interfaces/ports at boundaries
🌐 API Styles — REST vs GraphQL vs gRPC
Choosing an API style is a consequential decision: it shapes frontend development, mobile apps, and service-to-service communication. Each style has its own strengths and use cases — and you do not have to pick only one.
Plenty of companies run several styles at once: REST for the public API, GraphQL for the mobile app, gRPC for internal services. Understanding the trade-offs of each is what lets you choose correctly.
REST — Resource-based
REST (Representational State Transfer) is an architectural style that uses HTTP methods (GET, POST, PUT, DELETE) against URLs to manage resources. It is a standard that is easy to understand, has enormous tooling support, and caches well over HTTP.
But REST has its problems: over-fetching (you get more data than you asked for), under-fetching (you have to call several endpoints), and the N+1 problem that comes from fetching related data separately.
- Over-fetching — you ask for a user and get every field back
- Under-fetching — you have to call several endpoints
- N+1 problem — a list whose related data must be fetched separately
GraphQL — Query-based
GraphQL solves REST's problem by letting the client state exactly what data it wants, through a single endpoint — one POST /graphql. The client writes a query naming the fields it needs, and the server returns only those.
The upside is that over- and under-fetching disappear, the number of API calls drops, the schema is strongly typed, and introspection powers the development tools. The downside is that caching is hard, complexity is higher, and you have to learn the GraphQL query language.
The client states what data it wants
user(id: 123) {
name
avatar
posts(limit: 5) {
title
likes
}
}
}
"data": {
"user": {
"name": "Alice",
"avatar": "url...",
"posts": [...]
}
}
}
# ═══════════════════════════════
# GraphQL Schema Definition
# ═══════════════════════════════
type User {
id: ID!
name: String!
email: String!
avatar: String
posts(limit: Int = 10, offset: Int = 0): [Post!]!
followers: Int!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
likes: Int!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
posts(status: PostStatus, limit: Int): [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
# ═══════════════════════════════
# Client Queries
# ═══════════════════════════════
# Dashboard — 1 request instead of 4
query Dashboard {
me: user(id: "123") {
name
avatar
followers
}
recentPosts: posts(limit: 5) {
title
likes
createdAt
}
}
# Mutation
mutation CreatePost {
createPost(input: {
title: "GraphQL is Awesome"
content: "Here's why..."
}) {
id
title
createdAt
}
}
gRPC — Binary Protocol (Service-to-Service)
gRPC is a high-performance RPC framework that uses Protocol Buffers (protobuf) as its serialization format instead of JSON, which makes it far faster, strongly typed from the proto definition, and capable of streaming for real-time communication.
It is an excellent fit for service-to-service communication inside microservices, thanks to its performance, type safety and code generation. It is a poor fit for the browser, where it needs a grpc-web proxy, and its learning curve is steeper than REST's.
- Binary protocol (Protocol Buffers) → 10x faster
- Strongly typed → contract-first development
- Streaming support → real-time data
- Code generation → auto-generate client/server
(Node.js)
(binary)
(Go)
(text, slow)
// ═══════════════════════════════
// user.proto — Protocol Buffer Definition
// ═══════════════════════════════
syntax = "proto3";
package user;
service UserService {
// Unary — a single request/response
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
// Server streaming — the server streams back
rpc ListUsers (ListUsersRequest) returns (stream User);
// Bidirectional streaming — both sides stream
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message User {
string id = 1;
string name = 2;
string email = 3;
UserRole role = 4;
int64 created_at = 5;
}
enum UserRole {
MEMBER = 0;
EDITOR = 1;
ADMIN = 2;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
string password = 3;
}
REST vs GraphQL vs gRPC — How Do You Choose?
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Format | JSON (text) | JSON (text) | Protobuf (binary) |
| Transport | HTTP/1.1 | HTTP/1.1 | HTTP/2 |
| Performance | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
| Type Safety | ❌ Manual | ✅ Schema | ✅ Proto |
| Browser Support | ✅ Native | ✅ Library | ⚠️ grpc-web |
| Over/Under fetch | ❌ Common | ✅ Exact | ✅ Exact |
| Caching | ✅ HTTP cache | ⚠️ Complex | ❌ Hard |
| Learning Curve | ⭐ Easy | ⭐⭐ Medium | ⭐⭐⭐ Hard |
| Best For | Public APIs, CRUD, simple | Mobile/SPA, complex UI | Microservice internal |
💡 Common patterns:
├── Public API → REST (simple, cacheable, everyone knows it)
├── Frontend (SPA/Mobile) → GraphQL (flexible queries)
├── Service-to-service → gRPC (fast, typed, streaming)
└── Mix! REST for public + gRPC for internal is common
📨 Event-Driven Architecture & Message Queues
Event-driven architecture designs the system to work asynchronously through events and message queues instead of synchronous API calls. That makes the system more resilient, lets it scale better, and stops one struggling service from taking the whole system down.
The main benefits are loose coupling between services, fault tolerance — because the message queue buffers messages — and scalability, because each service can process messages at its own pace.
Synchronous vs Asynchronous
Take placing an order as the example. In the synchronous version, the user clicks buy and then has to wait while the system creates the order, charges the payment, updates inventory, sends the email, sends the SMS, updates analytics and generates the invoice — all of it — before a response comes back.
In the asynchronous version, once the order is created and the payment charged, the system publishes an "order.created" event to a message queue and responds immediately. The rest — email, SMS, analytics — happens in the background, handled by the consumers subscribed to that event.
⚠️ If the email server is down → the whole order fails!
├── Send email .............. async
├── Send SMS ................ async
├── Update analytics ........ async
└── Generate invoice ........ async
✅ Email down → automatic retry → the order does not fail
Message Queue Architecture
A message queue is a broker that holds messages temporarily: the producer (the service sending the message) puts a message into the queue, and the consumer (the service receiving it) pulls the message out and processes it.
The advantage is that if a consumer dies, the message is still sitting in the queue rather than lost — and you can run several consumers competing for the same work (competing consumers), which scales well. Popular message queues include RabbitMQ, Apache Kafka, Redis Pub/Sub and AWS SQS.
Event-Driven Example with RabbitMQ
// ═══════════════════════════════
// Producer — Order Service
// ═══════════════════════════════
import amqp from 'amqplib';
class OrderService {
private channel: amqp.Channel;
async connect() {
const conn = await amqp.connect('amqp://localhost');
this.channel = await conn.createChannel();
// Declare exchange (topic-based routing)
await this.channel.assertExchange('events', 'topic', { durable: true });
}
async createOrder(userId: string, items: CartItem[]) {
// 1. Business logic
const order = await this.orderRepo.create({ userId, items });
// 2. Publish event (async, non-blocking)
const event = {
type: 'order.created',
data: {
orderId: order.id,
userId,
items,
total: order.total,
timestamp: new Date().toISOString(),
},
};
this.channel.publish(
'events',
'order.created',
Buffer.from(JSON.stringify(event)),
{ persistent: true } // Survives broker restart
);
return order; // Return immediately!
}
}
// ═══════════════════════════════
// Consumer — Email Service
// ═══════════════════════════════
class EmailConsumer {
async start() {
const conn = await amqp.connect('amqp://localhost');
const channel = await conn.createChannel();
await channel.assertExchange('events', 'topic', { durable: true });
// Create queue for this consumer
const q = await channel.assertQueue('email-service', { durable: true });
// Bind to events we care about
await channel.bindQueue(q.queue, 'events', 'order.created');
await channel.bindQueue(q.queue, 'events', 'user.registered');
// Process messages
channel.prefetch(10); // Process 10 at a time
channel.consume(q.queue, async (msg) => {
if (!msg) return;
try {
const event = JSON.parse(msg.content.toString());
switch (event.type) {
case 'order.created':
await sendOrderConfirmation(event.data);
break;
case 'user.registered':
await sendWelcomeEmail(event.data);
break;
}
channel.ack(msg); // ✅ Message processed
} catch (err) {
channel.nack(msg, false, true); // ❌ Retry (requeue)
}
});
}
}
Apache Kafka — High-Throughput Event Streaming
Apache Kafka differs from an ordinary message queue because it is an "event log" more than a "message queue" — meaning events are retained for a period (7 days, say) and a consumer can replay them.
Kafka suits use cases that need high throughput (100K+ messages/second), event sourcing, log aggregation, or real-time analytics. RabbitMQ suits task queues, RPC, or notification systems that never need to replay.
(messages are deleted after consumption)
(events are retained, and can be replayed)
Partition 1: [e1][e2][e3]→
Partition 2: [e1][e2][e3][e4]→
Producers
(Order Service)
Consumer Groups
├── Email Service
├── Analytics Service
└── Inventory Service
- Retention: events kept for 7 days (configurable)
- Replay: a consumer can read back through history
- Partitions: parallel processing
- Consumer Groups: load balancing
- Throughput: 100K+ messages/second
- Ordering: per-partition ordering guarantee
RabbitMQ vs Kafka — Quick Comparison
| RabbitMQ | Apache Kafka | |
|---|---|---|
| Model | Message Queue | Event Log |
| Messages | Deleted after consumption | Retained (7d+) replay possible |
| Throughput | ~10K msg/sec | ~100K+ msg/sec |
| Ordering | Per-queue | Per-partition |
| Routing | ✅ Flexible (exchange types) | Topic-based |
| Protocol | AMQP | Custom binary |
| Complexity | ⭐⭐ Medium | ⭐⭐⭐ Complex |
| Use Cases | Task queues, notifications, RPC | Event sourcing, analytics, logs, streaming |
💡 Small/medium apps → RabbitMQ (simpler)
High-throughput, event sourcing → Kafka
Cloud-native → AWS SQS/SNS (simplest managed option)
🏗️ Communication Patterns in Distributed Systems
Once a system is split into multiple services, a new set of challenges arrives: how services talk to each other, how you manage failures, how you handle transactions that cross service boundaries, and how you observe system behavior.
These communication patterns are the proven solutions to exactly those problems. Each one has its own purpose and its own use cases, and choosing between them means weighing what you actually need against how much complexity you are willing to accept.
Gateway handles: routing, auth, rate limiting
Handles: mTLS, load balancing, observability
Each step has a compensating action
Write DB (normalized) ≠ Read DB (denormalized)
If Service B fails 5 times → stop calling
API Gateway is a single entry point that handles routing, authentication, rate limiting, and logging. It cuts complexity on the client side and centralizes cross-cutting concerns.
Service Mesh is an infrastructure layer that handles service-to-service communication through a sidecar proxy, giving you mTLS, load balancing, and observability without touching application code.
Saga Pattern solves distributed transactions by breaking them into steps, with a compensating action for each step in case that step fails.
CQRS separates the write model (normalized for consistency) from the read model (denormalized for performance). It suits read-heavy systems.
Circuit Breaker prevents cascading failures by stopping calls to a service that has failed, then retrying gradually as the system recovers.
🌍 Real-World Architecture Examples
Looking at real-world architectures makes it far clearer how these patterns actually get used, and what scale each one fits. Here we will look at two: a medium-sized e-commerce platform, and a SaaS startup.
E-commerce Platform (Medium Scale)
→ Static assets (images, CSS, JS)
Multiple instances
→ Auth, rate limiting, logging
SaaS Application (Simpler — Modular Monolith)
Auth | Users | Billing | Teams | API Modules
(cache)
(files)
(BullMQ)
E-commerce Architecture shows a layered approach: a CDN for static assets, a load balancer for distribution, multiple web app instances for scalability, an API gateway for API management, and microservices split along domain lines.
SaaS Architecture shows the approach for a startup that picks a modular monolith to stay simple, while keeping the modularity it will need to split into microservices later, plus managed services to cut operational overhead.
📋 Architecture Decision Cheat Sheet
There is no universal "correct answer" when choosing an architecture, because everything is a trade-off measured against the context of the project, the team, the timeline, the budget, and the requirements. These guidelines will at least make the decision easier.
🔑 Key Takeaways
Having walked through architecture patterns, API styles, design patterns, and communication patterns, let us gather the lessons that matter — the ones that help you choose correctly and keep you from over-engineering.
- Start Monolith, Split Later → don't begin with microservices unless you have to — a modular monolith is good enough for 90% of startups
- MVC → Layered → Clean → match the complexity to the project — MVC ships just fine!
- REST for public, GraphQL for SPA, gRPC for internal → you don't have to pick only one; they work together
- Event-driven = resilient → use message queues for work that doesn't need an immediate response
- RabbitMQ vs Kafka → RabbitMQ for task queues, Kafka for event streaming
- Saga ≠ distributed transaction → every step carries its own compensating action
- CQRS → separate the write model from the read model when you are read-heavy
- Circuit Breaker → prevents cascading failures in distributed systems
- API Gateway → a single entry point for auth, rate limiting, and routing
- Architecture is trade-offs → there is no "best" architecture — only "right for this situation"