Episode 3 — NodeJS MongoDB Backend Architecture / 3.3 — Backend Architectures
3.3.d — SOA and Other Architectures
Beyond MVC lies a world of architectural patterns designed for scale, resilience, and team independence. This file covers Service-Oriented Architecture (SOA), Microservices, Event-Driven Architecture, and Serverless, with practical comparisons and guidance on when to use each one.
Home | Prev: 3.3.c — MVC with REST APIs | Next: 3.3-Exercise-Questions
1. Service-Oriented Architecture (SOA)
SOA organizes an application as a collection of services that communicate over a network. Each service provides a specific business capability and exposes it through a well-defined interface.
Core Principles of SOA
| Principle | Meaning |
|---|---|
| Service contract | Each service has a clear API definition |
| Loose coupling | Services minimize dependencies on each other |
| Abstraction | Internal implementation is hidden from consumers |
| Reusability | Services can be used by multiple consumers |
| Composability | Services can be combined to create higher-level operations |
| Discoverability | Services can be found and understood through a registry |
SOA Architecture Diagram
+------------------+ +------------------+ +------------------+
| Web Frontend | | Mobile App | | Partner System |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
+--------+------------------------+------------------------+--------+
| API Gateway / ESB |
| (Enterprise Service Bus / Load Balancer) |
+--------+----------+----------+-----------+----------+-------------+
| | | | |
+-----+---+ +---+-----+ +--+------+ +--+------+ +--+-------+
| User | | Order | | Payment | | Inventory| | Notif. |
| Service | | Service | | Service | | Service | | Service |
+----+----+ +----+----+ +----+----+ +----+-----+ +----+-----+
| | | | |
+----+----+ +----+----+ +---+-----+ +---+------+ +----+----+
| User DB | |Order DB | |Payment | |Inventory | | Email / |
| | | | |Provider | | DB | | SMS |
+---------+ +---------+ +---------+ +----------+ +---------+
SOA in Node.js: A Simple Example
// User Service (runs on port 3001)
// services/userService/app.js
const express = require('express');
const app = express();
app.use(express.json());
const users = []; // In reality, a database
app.get('/api/users/:id', (req, res) => {
const user = users.find(u => u.id === req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
app.post('/api/users', (req, res) => {
const user = { id: Date.now().toString(), ...req.body };
users.push(user);
res.status(201).json(user);
});
app.listen(3001, () => console.log('User Service on port 3001'));
// Order Service (runs on port 3002)
// services/orderService/app.js
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
app.post('/api/orders', async (req, res) => {
const { userId, items } = req.body;
// Inter-service communication: call User Service
try {
const userResponse = await axios.get(`${USER_SERVICE_URL}/api/users/${userId}`);
const user = userResponse.data;
const order = {
id: Date.now().toString(),
userId: user.id,
userName: user.name,
items,
status: 'pending',
createdAt: new Date()
};
res.status(201).json(order);
} catch (error) {
if (error.response && error.response.status === 404) {
return res.status(400).json({ error: 'User not found' });
}
res.status(500).json({ error: 'Failed to create order' });
}
});
app.listen(3002, () => console.log('Order Service on port 3002'));
SOA vs. Monolith
| Aspect | Monolith | SOA |
|---|---|---|
| Deployment | Single unit | Each service deploys independently |
| Communication | Function calls (in-process) | Network calls (HTTP, messaging) |
| Database | Shared single database | Can share or have separate databases |
| Technology | One tech stack | Can mix technologies |
| Team ownership | Shared codebase | Teams own individual services |
| Complexity | Simple initially | More infrastructure needed |
2. Microservices Architecture
Microservices take SOA principles further: each service is small, independently deployable, and owns its own data.
Key Characteristics
| Characteristic | Description |
|---|---|
| Single responsibility | Each service does one thing well |
| Own database | No shared databases between services |
| Independent deployment | Deploy one service without affecting others |
| Decentralized governance | Teams choose their own tools and languages |
| Failure isolation | One service failing does not crash others |
| API-first | Services communicate through well-defined APIs |
Microservices Communication Patterns
SYNCHRONOUS (Request/Response) ASYNCHRONOUS (Message Queue)
+--------+ HTTP/gRPC +--------+ +--------+ publish +--------+
|Service | -----------> |Service | |Service | --------> | Queue |
| A | <----------- | B | | A | | |
+--------+ response +--------+ +--------+ +---+----+
|
consume |
+--------+ <------------+
|Service |
| B |
+--------+
Synchronous Communication Example
// API Gateway (routes requests to the right service)
// gateway/app.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use('/api/users', createProxyMiddleware({
target: 'http://user-service:3001',
changeOrigin: true
}));
app.use('/api/orders', createProxyMiddleware({
target: 'http://order-service:3002',
changeOrigin: true
}));
app.use('/api/products', createProxyMiddleware({
target: 'http://product-service:3003',
changeOrigin: true
}));
app.listen(3000, () => console.log('API Gateway on port 3000'));
Asynchronous Communication Example (with Redis)
// Publisher: Order Service publishes an event when order is created
// orderService/app.js
const Redis = require('ioredis');
const publisher = new Redis();
app.post('/api/orders', async (req, res) => {
const order = await Order.create(req.body);
// Publish event (fire and forget)
await publisher.publish('order:created', JSON.stringify({
orderId: order._id,
userId: order.userId,
items: order.items,
total: order.total
}));
res.status(201).json(order);
});
// Subscriber: Notification Service listens for order events
// notificationService/app.js
const Redis = require('ioredis');
const subscriber = new Redis();
subscriber.subscribe('order:created', (err) => {
if (err) console.error('Failed to subscribe:', err);
});
subscriber.on('message', async (channel, message) => {
if (channel === 'order:created') {
const orderData = JSON.parse(message);
// Send confirmation email
await sendEmail(orderData.userId, 'Order Confirmed', {
orderId: orderData.orderId,
total: orderData.total
});
console.log(`Email sent for order ${orderData.orderId}`);
}
});
// Subscriber: Inventory Service also listens for the same event
// inventoryService/app.js
subscriber.on('message', async (channel, message) => {
if (channel === 'order:created') {
const orderData = JSON.parse(message);
// Reduce stock for each item
for (const item of orderData.items) {
await Product.findByIdAndUpdate(item.productId, {
$inc: { stock: -item.quantity }
});
}
console.log(`Stock updated for order ${orderData.orderId}`);
}
});
3. Monolith vs. SOA vs. Microservices
| Factor | Monolith | SOA | Microservices |
|---|---|---|---|
| Size of services | Everything in one app | Medium-sized services | Small, focused services |
| Database | Single shared DB | May share databases | Each service owns its DB |
| Communication | In-process function calls | ESB or HTTP | HTTP, gRPC, message queues |
| Deployment | All at once | Service-level | Per micro-service |
| Team structure | One team, shared code | Teams per service area | Teams per micro-service |
| Data consistency | ACID transactions easy | Requires coordination | Eventual consistency |
| Debugging | Single log, single process | Moderate complexity | Distributed tracing needed |
| Initial cost | Low | Medium | High |
| Scaling | Scale everything together | Scale service groups | Scale individual services |
| Best team size | 1-10 developers | 10-50 developers | 20+ developers |
| Technology flexibility | One stack | Some flexibility | Full flexibility |
4. Event-Driven Architecture
In event-driven architecture, components communicate by producing and consuming events rather than calling each other directly.
Core Concepts
| Concept | Description |
|---|---|
| Event | A notification that something happened ("UserCreated", "OrderPlaced") |
| Producer | The component that emits the event |
| Consumer | The component that reacts to the event |
| Event bus/broker | The infrastructure that routes events (Redis, RabbitMQ, Kafka) |
| Event store | Optional: persistent log of all events |
Why Event-Driven?
WITHOUT EVENTS (tight coupling)
+--------+ +--------+ +--------+
| Order | -> | Stock | -> | Email |
|Service | |Service | |Service |
+--------+ +--------+ +--------+
Order service must KNOW about Stock and Email services.
Adding a new service means CHANGING Order service.
WITH EVENTS (loose coupling)
+--------+ +-----------+ +--------+
| Order | --> | Event Bus | --> | Stock |
|Service | | | --> | Email |
+--------+ | | --> | Analyt.|
+-----------+ +--------+
Order service just publishes "OrderPlaced."
Adding a new consumer requires ZERO changes to Order service.
Node.js Event-Driven Example (In-Process)
// events/eventBus.js
const EventEmitter = require('events');
class AppEventBus extends EventEmitter {
// Add logging for debugging
emit(event, ...args) {
console.log(`[EVENT] ${event} emitted at ${new Date().toISOString()}`);
return super.emit(event, ...args);
}
}
module.exports = new AppEventBus();
// services/orderService.js
const eventBus = require('../events/eventBus');
const Order = require('../models/Order');
class OrderService {
async placeOrder(userId, items) {
const order = await Order.create({ userId, items, status: 'pending' });
// Emit event - this service does not care who listens
eventBus.emit('order:placed', {
orderId: order._id,
userId,
items,
total: order.total
});
return order;
}
}
module.exports = new OrderService();
// listeners/inventoryListener.js
const eventBus = require('../events/eventBus');
const Product = require('../models/Product');
eventBus.on('order:placed', async (data) => {
try {
for (const item of data.items) {
await Product.findByIdAndUpdate(item.productId, {
$inc: { stock: -item.quantity }
});
}
console.log(`[INVENTORY] Stock updated for order ${data.orderId}`);
} catch (error) {
console.error(`[INVENTORY] Failed to update stock:`, error);
// In production: send to dead-letter queue for retry
}
});
// listeners/notificationListener.js
const eventBus = require('../events/eventBus');
const emailService = require('../services/emailService');
eventBus.on('order:placed', async (data) => {
try {
await emailService.sendOrderConfirmation(data.userId, data.orderId);
console.log(`[NOTIFICATION] Email sent for order ${data.orderId}`);
} catch (error) {
console.error(`[NOTIFICATION] Failed to send email:`, error);
}
});
// app.js - Register all listeners at startup
require('./listeners/inventoryListener');
require('./listeners/notificationListener');
// Adding a new listener requires ZERO changes to existing code
5. Serverless Architecture
Serverless (Functions as a Service / FaaS) means you write individual functions that a cloud provider runs on demand. You never manage servers.
How Serverless Works
Traditional Server Serverless
+---------------------+ +---------+
| Your Express server | | fn: A() | <- Cloud runs on demand
| running 24/7/365 | +---------+
| paying even when | | fn: B() | <- Scales automatically
| nobody uses it | +---------+
+---------------------+ | fn: C() | <- Pay per invocation
+---------+
SERVER IS ALWAYS ON FUNCTIONS RUN ONLY WHEN TRIGGERED
Pay for uptime Pay for execution time
You manage scaling Auto-scales to zero
You manage OS/patches Cloud manages everything
Serverless Example: AWS Lambda
// functions/createUser.js (AWS Lambda)
const mongoose = require('mongoose');
const User = require('./models/User');
let isConnected = false;
// Reuse connection across invocations (Lambda optimization)
async function connectDB() {
if (isConnected) return;
await mongoose.connect(process.env.MONGODB_URI);
isConnected = true;
}
exports.handler = async (event) => {
try {
await connectDB();
const body = JSON.parse(event.body);
const user = await User.create(body);
return {
statusCode: 201,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'success',
data: { user }
})
};
} catch (error) {
return {
statusCode: 400,
body: JSON.stringify({
status: 'fail',
message: error.message
})
};
}
};
// functions/processOrder.js (triggered by an event, not HTTP)
exports.handler = async (event) => {
// event comes from SQS queue, SNS topic, or DynamoDB stream
for (const record of event.Records) {
const order = JSON.parse(record.body);
await reduceInventory(order.items);
await sendConfirmationEmail(order.userId, order.orderId);
console.log(`Processed order ${order.orderId}`);
}
return { statusCode: 200, body: 'Processed' };
};
Serverless Pros and Cons
| Pros | Cons |
|---|---|
| Zero server management | Cold start latency (first invocation is slow) |
| Auto-scales to zero | Vendor lock-in (AWS Lambda vs Google Cloud Functions) |
| Pay only for execution time | Hard to debug locally |
| Built-in high availability | 15-minute execution limit (AWS Lambda) |
| Rapid deployment of individual functions | Complex state management |
| Great for event-triggered workloads | Not ideal for long-running processes |
6. When to Use Which Architecture (Decision Matrix)
Quick Decision Flowchart
START
|
v
Team size > 20?
| |
YES NO
| |
v v
Consider Is it a new project?
Micro- | |
services YES NO
| | |
| v v
| Start with Refactor toward
| Monolith clean layers
| (MVC) first
|
v
Do services need to scale independently?
| |
YES NO
| |
v v
Micro- SOA or Modular
services Monolith
|
v
Is the workload event-heavy?
| |
YES NO
| |
v v
Event- Standard
Driven Microservices
+ Micro- with REST/gRPC
services
Decision Matrix Table
| Scenario | Best Architecture | Why |
|---|---|---|
| MVP / Prototype | Monolith (MVC) | Speed of development, simplest deployment |
| REST API (small team) | MVC with Service Layer | Clean separation without infrastructure overhead |
| E-commerce platform (growing) | Modular Monolith -> SOA | Start organized, split when needed |
| Real-time chat application | Event-Driven | Messages are events; pub/sub is natural |
| Image processing pipeline | Serverless | Sporadic workload, scales to zero |
| Netflix-scale streaming | Microservices | Hundreds of teams, independent scaling |
| Webhook processor | Serverless | Triggered by external events, bursty traffic |
| Banking transaction system | SOA with Event Sourcing | Audit trail, data consistency requirements |
| Blog or content site | Monolith | Simplest possible, no complex business logic |
| IoT sensor data collection | Event-Driven + Serverless | High-volume events, variable load |
7. Real-World Examples
Netflix (Microservices)
+------------------+
| API Gateway |
| (Zuul) |
+--------+---------+
|
+--------+--------+------+------+--------+--------+
| | | | | |
+----+---+ +--+----+ ++-----+ +-----+-+ +----+---+ +--+----+
| User | | Search| |Stream| |Recommend| | Billing| |Content|
|Profile | |Service| |Service| |Engine | |Service | |Service|
+--------+ +-------+ +------+ +--------+ +--------+ +-------+
Each service:
- Has its own database
- Deploys independently
- Has its own team
- Can use different tech (Java, Node.js, Python)
Uber (SOA -> Microservices)
Evolution:
2010: Monolith (PHP)
2012: SOA (Python services)
2014: Microservices (Java/Go/Node.js)
Why they migrated:
- Monolith: deploy took hours, one bug = entire app down
- SOA: better, but shared databases caused bottlenecks
- Microservices: teams move independently, scale per service
Shopify (Modular Monolith)
+---------------------------------------------+
| Shopify (Ruby on Rails) |
| |
| +----------+ +----------+ +----------+ |
| | Checkout | | Shipping | | Payments | |
| | Module | | Module | | Module | |
| +----------+ +----------+ +----------+ |
| |
| Monolith with STRICT module boundaries |
| Modules cannot access each other's tables |
| Enforced by automated tooling |
+---------------------------------------------+
Lesson: You do NOT need microservices to scale.
Shopify handles millions of stores with a monolith.
8. Starting Monolith, Evolving to Microservices
This is the pragmatic approach most successful companies follow.
Phase 1: Well-Structured Monolith
project/
src/
modules/
users/
userRoutes.js
userController.js
userService.js
userModel.js
orders/
orderRoutes.js
orderController.js
orderService.js
orderModel.js
products/
productRoutes.js
productController.js
productService.js
productModel.js
shared/
middlewares/
utils/
app.js
server.js
Key: Organize by feature (not by layer). This makes extraction easier later.
Phase 2: Identify Extraction Candidates
Ask these questions:
- Which module has different scaling needs? (e.g., search gets 10x more traffic)
- Which module changes most frequently? (e.g., payment integrations)
- Which module could benefit from a different technology? (e.g., Python for ML recommendations)
- Which module causes deployment bottlenecks? (e.g., one team's changes block another)
Phase 3: Extract One Service at a Time
// BEFORE: Order service calls user module directly (in-process)
const userService = require('../users/userService');
async function createOrder(userId, items) {
const user = await userService.getUserById(userId); // direct call
// ... rest of order logic
}
// AFTER: Order service calls user microservice over HTTP
const axios = require('axios');
async function createOrder(userId, items) {
const { data } = await axios.get(
`${process.env.USER_SERVICE_URL}/api/users/${userId}`
);
const user = data.data.user; // HTTP call to separate service
// ... rest of order logic
}
Phase 4: Add Infrastructure
+-------------------+
| API Gateway | (routes external requests)
+--------+----------+
|
+--------+----------+--------+
| | |
v v v
User Service Order Product
(extracted) Service Service
(monolith)(monolith)
Over time, extract more services as the pain points demand it. Never extract "just because."
9. Architecture Anti-Patterns to Avoid
| Anti-Pattern | Description | Fix |
|---|---|---|
| Distributed Monolith | Microservices that are tightly coupled and must deploy together | Start with a real monolith; extract only when ready |
| Shared Database | Multiple services reading/writing the same database tables | Each service owns its data; communicate via APIs |
| Big Bang Rewrite | Rewriting the entire system at once | Strangle pattern: replace one piece at a time |
| Golden Hammer | Using microservices for everything, even simple apps | Match architecture to the actual problem |
| Chatty Services | Services making dozens of calls to each other per request | Batch calls, use caching, or reconsider service boundaries |
Key Takeaways
- SOA organizes applications into network-accessible services with clear contracts. It is the precursor to microservices.
- Microservices take SOA further: each service is small, owns its data, and deploys independently. This comes at the cost of infrastructure complexity.
- Event-driven architecture decouples producers from consumers. Adding new features means adding subscribers, not changing existing code.
- Serverless eliminates server management entirely. You pay only for execution time, but you accept vendor lock-in and cold starts.
- Start with a monolith, evolve when the pain justifies it. Almost every successful company (Netflix, Amazon, Uber) started with a monolith.
- Organize your monolith by feature, not by layer. This makes future extraction into services dramatically easier.
- There is no "best" architecture. There is only the architecture that best fits your team size, project complexity, and scaling needs at this moment.
Explain-It Challenge
Scenario: You are the tech lead for a startup that has grown from 5 to 30 developers. Your monolith is deployed 3 times a day, and deployments often fail because one team's changes break another team's feature. The search feature needs to handle 10x more traffic than the rest of the app.
Present a plan:
- Which service would you extract first and why?
- Would you use synchronous (HTTP) or asynchronous (message queue) communication for it?
- What database strategy would the extracted service use?
- How would you handle the transition period where some code is in the monolith and some is in the new service?
- What infrastructure would you need to add (API gateway, service discovery, monitoring)?
Home | Prev: 3.3.c — MVC with REST APIs | Next: 3.3-Exercise-Questions