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

PrincipleMeaning
Service contractEach service has a clear API definition
Loose couplingServices minimize dependencies on each other
AbstractionInternal implementation is hidden from consumers
ReusabilityServices can be used by multiple consumers
ComposabilityServices can be combined to create higher-level operations
DiscoverabilityServices 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

AspectMonolithSOA
DeploymentSingle unitEach service deploys independently
CommunicationFunction calls (in-process)Network calls (HTTP, messaging)
DatabaseShared single databaseCan share or have separate databases
TechnologyOne tech stackCan mix technologies
Team ownershipShared codebaseTeams own individual services
ComplexitySimple initiallyMore infrastructure needed

2. Microservices Architecture

Microservices take SOA principles further: each service is small, independently deployable, and owns its own data.

Key Characteristics

CharacteristicDescription
Single responsibilityEach service does one thing well
Own databaseNo shared databases between services
Independent deploymentDeploy one service without affecting others
Decentralized governanceTeams choose their own tools and languages
Failure isolationOne service failing does not crash others
API-firstServices 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

FactorMonolithSOAMicroservices
Size of servicesEverything in one appMedium-sized servicesSmall, focused services
DatabaseSingle shared DBMay share databasesEach service owns its DB
CommunicationIn-process function callsESB or HTTPHTTP, gRPC, message queues
DeploymentAll at onceService-levelPer micro-service
Team structureOne team, shared codeTeams per service areaTeams per micro-service
Data consistencyACID transactions easyRequires coordinationEventual consistency
DebuggingSingle log, single processModerate complexityDistributed tracing needed
Initial costLowMediumHigh
ScalingScale everything togetherScale service groupsScale individual services
Best team size1-10 developers10-50 developers20+ developers
Technology flexibilityOne stackSome flexibilityFull flexibility

4. Event-Driven Architecture

In event-driven architecture, components communicate by producing and consuming events rather than calling each other directly.

Core Concepts

ConceptDescription
EventA notification that something happened ("UserCreated", "OrderPlaced")
ProducerThe component that emits the event
ConsumerThe component that reacts to the event
Event bus/brokerThe infrastructure that routes events (Redis, RabbitMQ, Kafka)
Event storeOptional: 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

ProsCons
Zero server managementCold start latency (first invocation is slow)
Auto-scales to zeroVendor lock-in (AWS Lambda vs Google Cloud Functions)
Pay only for execution timeHard to debug locally
Built-in high availability15-minute execution limit (AWS Lambda)
Rapid deployment of individual functionsComplex state management
Great for event-triggered workloadsNot 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

ScenarioBest ArchitectureWhy
MVP / PrototypeMonolith (MVC)Speed of development, simplest deployment
REST API (small team)MVC with Service LayerClean separation without infrastructure overhead
E-commerce platform (growing)Modular Monolith -> SOAStart organized, split when needed
Real-time chat applicationEvent-DrivenMessages are events; pub/sub is natural
Image processing pipelineServerlessSporadic workload, scales to zero
Netflix-scale streamingMicroservicesHundreds of teams, independent scaling
Webhook processorServerlessTriggered by external events, bursty traffic
Banking transaction systemSOA with Event SourcingAudit trail, data consistency requirements
Blog or content siteMonolithSimplest possible, no complex business logic
IoT sensor data collectionEvent-Driven + ServerlessHigh-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:

  1. Which module has different scaling needs? (e.g., search gets 10x more traffic)
  2. Which module changes most frequently? (e.g., payment integrations)
  3. Which module could benefit from a different technology? (e.g., Python for ML recommendations)
  4. 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-PatternDescriptionFix
Distributed MonolithMicroservices that are tightly coupled and must deploy togetherStart with a real monolith; extract only when ready
Shared DatabaseMultiple services reading/writing the same database tablesEach service owns its data; communicate via APIs
Big Bang RewriteRewriting the entire system at onceStrangle pattern: replace one piece at a time
Golden HammerUsing microservices for everything, even simple appsMatch architecture to the actual problem
Chatty ServicesServices making dozens of calls to each other per requestBatch calls, use caching, or reconsider service boundaries

Key Takeaways

  1. SOA organizes applications into network-accessible services with clear contracts. It is the precursor to microservices.
  2. Microservices take SOA further: each service is small, owns its data, and deploys independently. This comes at the cost of infrastructure complexity.
  3. Event-driven architecture decouples producers from consumers. Adding new features means adding subscribers, not changing existing code.
  4. Serverless eliminates server management entirely. You pay only for execution time, but you accept vendor lock-in and cold starts.
  5. Start with a monolith, evolve when the pain justifies it. Almost every successful company (Netflix, Amazon, Uber) started with a monolith.
  6. Organize your monolith by feature, not by layer. This makes future extraction into services dramatically easier.
  7. 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:

  1. Which service would you extract first and why?
  2. Would you use synchronous (HTTP) or asynchronous (message queue) communication for it?
  3. What database strategy would the extracted service use?
  4. How would you handle the transition period where some code is in the monolith and some is in the new service?
  5. 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