Architecture Design Patterns Microservices

Web Architecture & Design Patterns — ออกแบบระบบให้ Scale ได้และดูแลง่าย 🏗️Web Architecture & Design Patterns — Building Systems That Scale and Stay Maintainable 🏗️

By Anirach Mingkhwan DevOps & Vibe Coding 2026 · 28 min read
Web Architecture — ออกแบบระบบให้ Scale ได้
ในบทความนี้
  1. 🐕 ทำไมต้องรู้ Architecture?
  2. 🏢 Monolith vs Microservices
  3. 📐 Design Patterns — MVC, MVVM, Clean Architecture
  4. 🌐 API Styles — REST vs GraphQL vs gRPC
  5. 📨 Event-Driven Architecture & Message Queues
  6. 🏗️ Communication Patterns in Distributed Systems
  7. 🌍 Real-World Architecture Examples
  8. 📋 Architecture Decision Cheat Sheet
  9. 🔑 Key Takeaways
In this post
  1. 🐕 Why Should You Care About Architecture?
  2. 🏢 Monolith vs Microservices
  3. 📐 Design Patterns — MVC, MVVM, Clean Architecture
  4. 🌐 API Styles — REST vs GraphQL vs gRPC
  5. 📨 Event-Driven Architecture & Message Queues
  6. 🏗️ Communication Patterns in Distributed Systems
  7. 🌍 Real-World Architecture Examples
  8. 📋 Architecture Decision Cheat Sheet
  9. 🔑 Key Takeaways

🐕 ทำไมต้องรู้ Architecture?

Architecture เป็นหัวใจสำคัญของการพัฒนาซอฟต์แวร์ที่หลายคนมองข้าม เพราะมันไม่ใช่แค่เรื่องของการเขียนโค้ดให้ทำงานได้ แต่เป็นการออกแบบโครงสร้างที่จะทำให้ระบบของเราสามารถ scale ได้ ดูแลรักษาได้ และพัฒนาต่อยอดได้ในระยะยาว

ความแตกต่างระหว่างโค้ดที่ดีกับ architecture ที่ดีนั้นเปรียบเสมือนความแตกต่างระหว่างการสร้างบ้านที่สวยงามกับการวางแปลนบ้านที่ดี บ้านที่สวยแต่แปลนแย่อาจพังได้ง่าย แต่บ้านที่แปลนดีแม้จะตกแต่งไม่สวยก็ยังแก้ไขและปรับปรุงได้

❌ ไม่คิด Architecture:
  • 1 ไฟล์ 10,000 บรรทัด
  • แก้จุดนึง → พังอีก 5 จุด
  • Deploy ทีนึง → ลุ้นทั้งทีม
  • Scale ไม่ได้ → server ล่มตอน peak
  • Developer ใหม่ต้อง 3 เดือนกว่าจะเข้าใจ
✅ คิด Architecture:
  • แต่ละส่วนแยกชัดเจน
  • แก้จุดนึง → ไม่กระทบจุดอื่น
  • 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 เร็วๆ

Monolith Architecture
Single Application
Auth Module
Users Module
Products Module
Orders Module
Payments Module
Notifications Module
Shared Database (1)
  • Deploy ทีเดียว ทั้งก้อน
  • Scale ทีเดียว ทั้งก้อน
  • พังทีเดียว... ก็ทั้งก้อน 💀
Microservices Architecture
Auth Service
Node.js:3001
Redis
Users Service
Python:3002
PostgreSQL
Products Service
Go:3003
MongoDB
Orders Service
Java:3004
PostgreSQL
  • แต่ละ 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 แล้วค่อยแยก"

Modular Monolith (แนะนำ!)
Single Deployment
Auth Module
Routes, Models, Logic
Users Module
Routes, Models, Logic
Products Module
Routes, Models, Logic
Orders Module
Routes, Models, Logic
Communicate via Internal APIs only
(NOT direct DB access)
Shared Database
(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
src/
  modules/
    auth/
      auth.controller.ts
      auth.service.ts
      auth.routes.ts
      auth.model.ts
      auth.types.ts
    users/
      users.controller.ts
      users.service.ts
      users.routes.ts
      users.model.ts
      users.types.ts
    products/
      products.controller.ts
      products.service.ts
      products.routes.ts
      ...
  shared/
    middleware/
    database/
    utils/
  app.ts
  server.ts

📐 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 กลับไป)

MVC Pattern Flow
View
(Template)
HTML/EJS
←→
Controller
(Logic)
Routes + Handlers
↕
Model
(Data + Business Logic)
Examples: Rails, Django, Laravel, Express + EJS
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

Layered (N-Tier) Architecture
Presentation Layer (Controllers / Routes)
→ HTTP requests, validation, response format
↓
Service Layer (Business Logic)
→ Business rules, orchestration, validation
↓
Repository Layer (Data Access)
→ Database queries, ORM, external APIs
↓
Database / External Services
กฎ: แต่ละ layer เรียกได้แค่ layer ข้างล่าง
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

Clean Architecture (Onion)
Frameworks & Drivers
Express, PostgreSQL, Redis, AWS S3
Interface Adapters
Controllers, Presenters, Gateways
Enterprise
Entities
Domain Models
Dependency Rule:
  • dependencies point INWARD only
  • Inner layers know NOTHING about outer layers
  • Use interfaces/ports at boundaries
✅ Testable: mock outer layers
✅ Framework-independent: swap Express → Fastify
✅ DB-independent: swap PostgreSQL → MongoDB
⚠️ More code & complexity

🌐 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 แยกกัน

REST API
HTTP Method + URL = Operation on Resource
GET /api/users
List users
GET /api/users/123
Get user 123
POST /api/users
Create user
PUT /api/users/123
Update user 123 (full)
DELETE /api/users/123
Delete user 123
Nested resources:
GET /api/users/123/posts
GET /api/posts/456/comments
REST Problems:
  • 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

GraphQL API
POST /graphql
Single endpoint
Client บอกว่าต้องการ data อะไร
Query:
{
  user(id: 123) {
    name
    avatar
    posts(limit: 5) {
      title
      likes
    }
  }
}
Response:
{
  "data": {
    "user": {
      "name": "Alice",
      "avatar": "url...",
      "posts": [...]
    }
  }
}
1 request! เลือกเฉพาะ fields ที่ต้องการ!
# ═══════════════════════════════
# 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

gRPC
  • Binary protocol (Protocol Buffers) → 10x faster
  • Strongly typed → contract-first development
  • Streaming support → real-time data
  • Code generation → auto-generate client/server
Service A
(Node.js)
gRPC
(binary)
Service B
(Go)
vs
Service A
HTTP/JSON
(text, slow)
Service B
// ═══════════════════════════════
// 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 นั้น

❌ Synchronous (blocking)
User clicks "Place Order"
├── Create order .......... 100ms
├── Charge payment ........ 500ms ← 3rd party!
├── Update inventory ...... 100ms
├── Send email ............ 300ms ← 3rd party!
├── Send SMS .............. 400ms ← 3rd party!
├── Update analytics ...... 100ms
└── Generate invoice ...... 200ms
Total: 1,700ms ← User waits 1.7 seconds! 😤
⚠️ ถ้า email server ล่ม → ทั้ง order fail!
✅ Asynchronous (event-driven)
User clicks "Place Order"
├── Create order .......... 100ms
├── Charge payment ........ 500ms
├── Publish event: "order.created" → Queue
└── Response: "Order placed!" ← 600ms total! ⚡
Message Queue processes (background):
├── Send email .............. async
├── Send SMS ................ async
├── Update analytics ........ async
└── Generate invoice ........ async
⚡ User gets response in 600ms!
✅ 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

Message Queue Pattern
Producer
(ผู้ส่ง)

Order Service
order.created queue
message 1
message 2
message 3
Consumer
(ผู้รับ)

Email Service
SMS Service
Analytics
How it works:
  1. Producer ส่ง message เข้า queue
  2. Queue เก็บ message ไว้ (persistent)
  3. Consumer ดึง message มาทำงาน
  4. ถ้า consumer fail → message ยังอยู่ใน queue
  5. Consumer อื่นมารับทำแทนได้ (competing consumers)

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

Apache Kafka
RabbitMQ = Message Queue
(messages ถูกลบหลัง consume)
Kafka = Event Log
(events ถูกเก็บถาวร, replay ได้)
Topic: orders
Partition 0: [e1][e2][e3][e4][e5]→
Partition 1: [e1][e2][e3]→
Partition 2: [e1][e2][e3][e4]→
↑
Producers
(Order Service)
→
↓ ↓
Consumer Groups
├── Email Service
├── Analytics Service
└── Inventory Service
Key Features:
  • 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 ที่แตกต่างกัน การเลือกใช้ต้องดูจากความต้องการและความซับซ้อนที่ยอมรับได้

1. API Gateway
Client → API Gateway → Services
Gateway handles: routing, auth, rate limiting
Tools: Kong, AWS API Gateway, Nginx
2. Service Mesh
Sidecar proxy per service (Envoy)
Handles: mTLS, load balancing, observability
Tools: Istio, Linkerd
3. Saga Pattern
Distributed transactions
Each step has a compensating action
Example: Order → Stock → Payment → Compensate if fail
4. CQRS
Command Query Responsibility Segregation
Write DB (normalized) ≠ Read DB (denormalized)
Sync via events between the two DBs
5. Circuit Breaker
Closed → Open → Half-Open → Closed
If Service B fails 5 times → stop calling
Prevents cascading failures

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)

E-commerce Architecture (Medium Scale)
CDN (CloudFlare / CloudFront)
→ Static assets (images, CSS, JS)
Load Balancer (Nginx / ALB)
Web App (Next.js SSR)
Multiple instances
API Gateway (Kong)
→ Auth, rate limiting, logging
User Service
Product Service
Order Service
Payment Service
PostgreSQL + Redis + RabbitMQ + Elasticsearch

SaaS Application (Simpler — Modular Monolith)

SaaS App Architecture (Startup Stage)
Frontend (React/Next.js on Vercel)
API Server (Modular Monolith — Node.js)
Auth | Users | Billing | Teams | API Modules
PostgreSQL (single DB, schema per module)
Redis
(cache)
S3/R2
(files)
Background Jobs
(BullMQ)
💡 เริ่มง่าย, scale ได้, ค่อย extract ทีหลัง

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 เหล่านี้จะช่วยให้ตัดสินใจได้ง่ายขึ้น

📦 Architecture
Solo/small team (1-5)
→ Modular Monolith
Medium team (5-20)
→ Modular Monolith + Queue
Large team (20+)
→ Microservices
"I'm not sure"
→ Monolith (you can always split)
🌐 API Style
Public API
→ REST (everyone knows it)
Mobile/SPA with complex data
→ GraphQL
Service-to-service
→ gRPC (speed + type safety)
Real-time
→ WebSocket or Server-Sent Events
📨 Communication
Need immediate response
→ Sync (REST/gRPC)
Can delay processing
→ Async (Message Queue)
Need event replay
→ Kafka
Simple task queue
→ RabbitMQ or BullMQ
Managed + simple
→ AWS SQS/SNS
📐 Design Pattern
Small app
→ MVC (simple, proven)
Medium app
→ Layered (Controller-Service-Repo)
Complex domain
→ Clean/Hexagonal Architecture
"I'm overthinking"
→ MVC is fine, ship it!

🔑 Key Takeaways

หลังจากไปดู architecture patterns, API styles, design patterns, และ communication patterns ต่างๆ แล้ว มาสรุป lessons สำคัญที่จะช่วยให้เลือกใช้ได้ถูกต้องและไม่ over-engineer

  1. Start Monolith, Split Later → อย่าเริ่มด้วย microservices ถ้าไม่จำเป็น — modular monolith ดีพอสำหรับ 90% ของ startups
  2. MVC → Layered → Clean → เลือก complexity ให้เหมาะกับ project — MVC ก็ ship ได้!
  3. REST for public, GraphQL for SPA, gRPC for internal → ไม่ต้องเลือกอันเดียว ใช้ร่วมกันได้
  4. Event-driven = resilient → ใช้ message queues สำหรับงานที่ไม่ต้อง respond ทันที
  5. RabbitMQ vs Kafka → Task queue ใช้ RabbitMQ, event streaming ใช้ Kafka
  6. Saga ≠ distributed transaction → แต่ละ step มี compensating action
  7. CQRS → แยก write model กับ read model ถ้า read-heavy
  8. Circuit Breaker → ป้องกัน cascading failures ใน distributed systems
  9. API Gateway → Single entry point สำหรับ auth, rate limit, routing
  10. 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.

❌ No architectural thinking:
  • 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
✅ Architectural thinking:
  • 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.

Monolith Architecture
Single Application
Auth Module
Users Module
Products Module
Orders Module
Payments Module
Notifications Module
Shared Database (1)
  • Deploy once, the whole thing
  • Scale once, the whole thing
  • Break once... also the whole thing 💀
Microservices Architecture
Auth Service
Node.js:3001
Redis
Users Service
Python:3002
PostgreSQL
Products Service
Go:3003
MongoDB
Orders Service
Java:3004
PostgreSQL
  • 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."

Modular Monolith (recommended!)
Single Deployment
Auth Module
Routes, Models, Logic
Users Module
Routes, Models, Logic
Products Module
Routes, Models, Logic
Orders Module
Routes, Models, Logic
Communicate via Internal APIs only
(NOT direct DB access)
Shared Database
(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
src/
  modules/
    auth/
      auth.controller.ts
      auth.service.ts
      auth.routes.ts
      auth.model.ts
      auth.types.ts
    users/
      users.controller.ts
      users.service.ts
      users.routes.ts
      users.model.ts
      users.types.ts
    products/
      products.controller.ts
      products.service.ts
      products.routes.ts
      ...
  shared/
    middleware/
    database/
    utils/
  app.ts
  server.ts

📐 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).

MVC Pattern Flow
View
(Template)
HTML/EJS
←→
Controller
(Logic)
Routes + Handlers
↕
Model
(Data + Business Logic)
Examples: Rails, Django, Laravel, Express + EJS
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.

Layered (N-Tier) Architecture
Presentation Layer (Controllers / Routes)
→ HTTP requests, validation, response format
↓
Service Layer (Business Logic)
→ Business rules, orchestration, validation
↓
Repository Layer (Data Access)
→ Database queries, ORM, external APIs
↓
Database / External Services
The rule: each layer may call only the layer below it
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.

Clean Architecture (Onion)
Frameworks & Drivers
Express, PostgreSQL, Redis, AWS S3
Interface Adapters
Controllers, Presenters, Gateways
Enterprise
Entities
Domain Models
Dependency Rule:
  • dependencies point INWARD only
  • Inner layers know NOTHING about outer layers
  • Use interfaces/ports at boundaries
✅ Testable: mock outer layers
✅ Framework-independent: swap Express → Fastify
✅ DB-independent: swap PostgreSQL → MongoDB
⚠️ More code & complexity

🌐 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.

REST API
HTTP Method + URL = Operation on Resource
GET /api/users
List users
GET /api/users/123
Get user 123
POST /api/users
Create user
PUT /api/users/123
Update user 123 (full)
DELETE /api/users/123
Delete user 123
Nested resources:
GET /api/users/123/posts
GET /api/posts/456/comments
REST Problems:
  • 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.

GraphQL API
POST /graphql
Single endpoint
The client states what data it wants
Query:
{
  user(id: 123) {
    name
    avatar
    posts(limit: 5) {
      title
      likes
    }
  }
}
Response:
{
  "data": {
    "user": {
      "name": "Alice",
      "avatar": "url...",
      "posts": [...]
    }
  }
}
1 request! Only the fields you actually need!
# ═══════════════════════════════
# 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.

gRPC
  • Binary protocol (Protocol Buffers) → 10x faster
  • Strongly typed → contract-first development
  • Streaming support → real-time data
  • Code generation → auto-generate client/server
Service A
(Node.js)
gRPC
(binary)
Service B
(Go)
vs
Service A
HTTP/JSON
(text, slow)
Service B
// ═══════════════════════════════
// 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.

❌ Synchronous (blocking)
User clicks "Place Order"
├── Create order .......... 100ms
├── Charge payment ........ 500ms ← 3rd party!
├── Update inventory ...... 100ms
├── Send email ............ 300ms ← 3rd party!
├── Send SMS .............. 400ms ← 3rd party!
├── Update analytics ...... 100ms
└── Generate invoice ...... 200ms
Total: 1,700ms ← User waits 1.7 seconds! 😤
⚠️ If the email server is down → the whole order fails!
✅ Asynchronous (event-driven)
User clicks "Place Order"
├── Create order .......... 100ms
├── Charge payment ........ 500ms
├── Publish event: "order.created" → Queue
└── Response: "Order placed!" ← 600ms total! ⚡
Message Queue processes (background):
├── Send email .............. async
├── Send SMS ................ async
├── Update analytics ........ async
└── Generate invoice ........ async
⚡ User gets response in 600ms!
✅ 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.

Message Queue Pattern
Producer
(the sender)

Order Service
order.created queue
message 1
message 2
message 3
Consumer
(the receiver)

Email Service
SMS Service
Analytics
How it works:
  1. The producer sends a message into the queue
  2. The queue stores the message (persistent)
  3. A consumer pulls the message and does the work
  4. If the consumer fails → the message is still in the queue
  5. Another consumer can pick it up instead (competing consumers)

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.

Apache Kafka
RabbitMQ = Message Queue
(messages are deleted after consumption)
Kafka = Event Log
(events are retained, and can be replayed)
Topic: orders
Partition 0: [e1][e2][e3][e4][e5]→
Partition 1: [e1][e2][e3]→
Partition 2: [e1][e2][e3][e4]→
↑
Producers
(Order Service)
→
↓ ↓
Consumer Groups
├── Email Service
├── Analytics Service
└── Inventory Service
Key Features:
  • 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.

1. API Gateway
Client → API Gateway → Services
Gateway handles: routing, auth, rate limiting
Tools: Kong, AWS API Gateway, Nginx
2. Service Mesh
Sidecar proxy per service (Envoy)
Handles: mTLS, load balancing, observability
Tools: Istio, Linkerd
3. Saga Pattern
Distributed transactions
Each step has a compensating action
Example: Order → Stock → Payment → Compensate if fail
4. CQRS
Command Query Responsibility Segregation
Write DB (normalized) ≠ Read DB (denormalized)
Sync via events between the two DBs
5. Circuit Breaker
Closed → Open → Half-Open → Closed
If Service B fails 5 times → stop calling
Prevents cascading failures

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)

E-commerce Architecture (Medium Scale)
CDN (CloudFlare / CloudFront)
→ Static assets (images, CSS, JS)
Load Balancer (Nginx / ALB)
Web App (Next.js SSR)
Multiple instances
API Gateway (Kong)
→ Auth, rate limiting, logging
User Service
Product Service
Order Service
Payment Service
PostgreSQL + Redis + RabbitMQ + Elasticsearch

SaaS Application (Simpler — Modular Monolith)

SaaS App Architecture (Startup Stage)
Frontend (React/Next.js on Vercel)
API Server (Modular Monolith — Node.js)
Auth | Users | Billing | Teams | API Modules
PostgreSQL (single DB, schema per module)
Redis
(cache)
S3/R2
(files)
Background Jobs
(BullMQ)
💡 Start simple, scale when you need to, extract later

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.

📦 Architecture
Solo/small team (1-5)
→ Modular Monolith
Medium team (5-20)
→ Modular Monolith + Queue
Large team (20+)
→ Microservices
"I'm not sure"
→ Monolith (you can always split)
🌐 API Style
Public API
→ REST (everyone knows it)
Mobile/SPA with complex data
→ GraphQL
Service-to-service
→ gRPC (speed + type safety)
Real-time
→ WebSocket or Server-Sent Events
📨 Communication
Need immediate response
→ Sync (REST/gRPC)
Can delay processing
→ Async (Message Queue)
Need event replay
→ Kafka
Simple task queue
→ RabbitMQ or BullMQ
Managed + simple
→ AWS SQS/SNS
📐 Design Pattern
Small app
→ MVC (simple, proven)
Medium app
→ Layered (Controller-Service-Repo)
Complex domain
→ Clean/Hexagonal Architecture
"I'm overthinking"
→ MVC is fine, ship it!

🔑 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.

  1. Start Monolith, Split Later → don't begin with microservices unless you have to — a modular monolith is good enough for 90% of startups
  2. MVC → Layered → Clean → match the complexity to the project — MVC ships just fine!
  3. REST for public, GraphQL for SPA, gRPC for internal → you don't have to pick only one; they work together
  4. Event-driven = resilient → use message queues for work that doesn't need an immediate response
  5. RabbitMQ vs Kafka → RabbitMQ for task queues, Kafka for event streaming
  6. Saga ≠ distributed transaction → every step carries its own compensating action
  7. CQRS → separate the write model from the read model when you are read-heavy
  8. Circuit Breaker → prevents cascading failures in distributed systems
  9. API Gateway → a single entry point for auth, rate limiting, and routing
  10. Architecture is trade-offs → there is no "best" architecture — only "right for this situation"
บทความจากซีรีส์ DevOps & Vibe Coding 2026From the DevOps & Vibe Coding 2026 series
← Previous
Authentication & Authorization — ระบบยืนยันตัวตนและจัดการสิทธิ์อย่างปลอดภัย
Next →
Frontend Performance & Modern Frameworks — เพิ่มความเร็วและเลือก Framework ที่ใช่