SOFTWARE ENGINEERING · ~12 MIN READ

Microservices in practice:
understanding the architecture with Node.js and TypeScript

Think of an AI platform. Instead of putting users, conversations, files, and model requests on the same server, each part becomes a separate service.

Microservices in practice: a client sends a request to an API Gateway, which routes it to the User Service, Conversation Service, File Service, or Model Gateway, each with its own database or storage. A checklist reads: monolith vs microservices, service boundaries, communication via APIs, independent deploys, when and why to use.

SECTION 01

What is microservices architecture

Microservices architecture is a way of structuring a system as a set of small, independent services, instead of a single application.

Each service runs in its own process, has its own responsibility, and communicates with the others through an API.

SECTION 02

Example of a monolithic application

Diagram of a monolith: one codebase containing Users, Conversations, Files, and Model Requests, next to a code snippet with routes for each domain inside the same Express app. A callout reads: harder to scale, harder to deploy, tight coupling.
MONOLITH · EVERYTHING IN ONE APP

Everything lives inside the same project:

MONOLITHTYPESCRIPT
import express from "express";

const app = express();
app.use(express.json());

const users = [
  { id: 1, name: "Marcelo" }
];

const conversations = [
  { id: 1, title: "Onboarding" }
];

const messages: any[] = [];
const files: any[] = [];

app.get("/users/:id", (req, res) => {
  const user = users.find(
    user => user.id === Number(req.params.id)
  );

  res.json(user);
});

app.get("/conversations/:id", (req, res) => {
  const conversation = conversations.find(
    conversation => conversation.id === Number(req.params.id)
  );

  res.json(conversation);
});

app.post("/conversations/:id/messages", (req, res) => {
  const message = {
    id: messages.length + 1,
    conversationId: Number(req.params.id),
    content: req.body.content
  };

  messages.push(message);

  res.status(201).json(message);
});

app.post("/files", (req, res) => {
  const file = {
    id: files.length + 1,
    name: req.body.name,
    size: req.body.size
  };

  files.push(file);

  res.status(201).json(file);
});

app.listen(3000);

Here, everything runs together:

STRUCTUREDIAGRAM
Application
├── Users
├── Conversations
├── Files
└── Model Requests

If you change how messages are generated, you usually need to redeploy the entire application.

SECTION 03

Splitting into User, Conversation, File, and Model Gateway

Four services bounded by responsibility: User Service manages users, Conversation Service manages chats and messages, File Service handles uploads and storage, Model Gateway routes requests to AI model providers. Each service has its own codebase, database or storage, API, and deploy.
SERVICES, BOUNDED BY RESPONSIBILITY

Now we split the system into four independent applications:

PORTSDIAGRAM
User Service           port 3001
Conversation Service   port 3002
File Service           port 3003
Model Gateway          port 3004

Each service has its own responsibility.

USER SERVICETYPESCRIPT
import express from "express";

const app = express();

const users = [
  { id: 1, name: "Marcelo" },
  { id: 2, name: "Ana" }
];

app.get("/users/:id", (req, res) => {
  const user = users.find(
    user => user.id === Number(req.params.id)
  );

  if (!user) {
    return res.status(404).json({
      message: "User not found"
    });
  }

  res.json(user);
});

app.listen(3001, () => {
  console.log("User Service running on port 3001");
});

Responsibility:

RESPONSIBILITYTEXT
Create, read, and update users
FILE SERVICETYPESCRIPT
import express from "express";

const app = express();
app.use(express.json());

const files: any[] = [];

app.post("/files", (req, res) => {
  const file = {
    id: files.length + 1,
    name: req.body.name,
    size: req.body.size
  };

  files.push(file);

  res.status(201).json(file);
});

app.listen(3003, () => {
  console.log("File Service running on port 3003");
});

Responsibility:

RESPONSIBILITYTEXT
Store and serve uploaded files
MODEL GATEWAYTYPESCRIPT
import express from "express";

const app = express();
app.use(express.json());

app.post("/generate", async (req, res) => {
  const { prompt } = req.body;

  // In production, this calls an AI provider
  // (OpenAI, Anthropic, etc.) behind the scenes.
  const reply = {
    text: `Reply to: ${prompt}`
  };

  res.json(reply);
});

app.listen(3004, () => {
  console.log("Model Gateway running on port 3004");
});

Responsibility:

RESPONSIBILITYTEXT
Route requests to AI model providers

The conversation service needs to talk to the other services through APIs.

CONVERSATION SERVICETYPESCRIPT
import express from "express";

const app = express();
app.use(express.json());

const conversations = [
  { id: 1, title: "Onboarding" }
];

const messages: any[] = [];

app.get("/conversations/:id", (req, res) => {
  const conversation = conversations.find(
    conversation => conversation.id === Number(req.params.id)
  );

  if (!conversation) {
    return res.status(404).json({
      message: "Conversation not found"
    });
  }

  res.json(conversation);
});

app.post("/conversations/:id/messages", async (req, res) => {
  const { userId, content } = req.body;

  const userResponse = await fetch(
    `http://localhost:3001/users/${userId}`
  );

  if (!userResponse.ok) {
    return res.status(400).json({
      message: "Invalid user"
    });
  }

  const modelResponse = await fetch(
    "http://localhost:3004/generate",
    { method: "POST", body: JSON.stringify({ prompt: content }) }
  );

  if (!modelResponse.ok) {
    return res.status(502).json({
      message: "Model request failed"
    });
  }

  const user = await userResponse.json();
  const reply = await modelResponse.json();

  const message = {
    id: messages.length + 1,
    conversationId: Number(req.params.id),
    user,
    content,
    reply: reply.text
  };

  messages.push(message);

  res.status(201).json(message);
});

app.listen(3002, () => {
  console.log("Conversation Service running on port 3002");
});

SECTION 04

Communication between services via API

In production, clients don't call each service directly. An API Gateway sits in front of everything and routes each request to the right service:

Client sending a request to an API Gateway, which routes it to the User Service, Conversation Service, File Service, or Model Gateway, each with its own database or storage. A checklist reads: independent deploys, scale only what you need, resilience, technology flexibility.
WITH MICROSERVICES: CLIENT → API GATEWAY → SERVICES

Behind the API Gateway, services still call each other directly when they need data from one another — like the Conversation Service asking the User Service and the Model Gateway before replying to a message. When someone sends a message:

REQUESTHTTP
POST http://localhost:8080/conversations/1/messages
Content-Type: application/json

{
  "userId": 1,
  "content": "Hello!"
}

The flow is:

FLOWDIAGRAM
Client
   │
   ▼
API Gateway
   │
   ▼
Conversation Service
   │
   ├── GET User Service
   │
   └── POST Model Gateway

The response could look like:

RESPONSEJSON
{
  "id": 1,
  "conversationId": 1,
  "user": {
    "id": 1,
    "name": "Marcelo"
  },
  "content": "Hello!",
  "reply": "Reply to: Hello!"
}

SECTION 05

Deploy and scalability independence

Each service can be developed and deployed separately.

STACK PER SERVICEDIAGRAM
User Service
- Node.js
- PostgreSQL
- 2 servers

File Service
- Go
- Object storage
- 3 servers

Conversation Service
- Python
- PostgreSQL
- 10 servers

Model Gateway
- Node.js
- No database
- 5 servers

Using different technologies isn't mandatory, but it's possible.

If the conversation service gets a lot more traffic, you scale only that one:

SCALEDIAGRAM
User Service
└── 1 instance

File Service
└── 1 instance

Conversation Service
├── instance 1
├── instance 2
├── instance 3
└── instance 4

SECTION 06

Loose coupling

The conversation service doesn't need to know the internal code of the user service.

It only knows the API:

CONTRACTHTTP
GET /users/1

The User Service can change completely on the inside:

USER SERVICE ON THE INSIDETYPESCRIPT
// Before
const user = users.find(...);

// After
const user = await database.users.findById(id);

As long as the API keeps returning the same format, the Conversation Service doesn't need to change.

SECTION 07

Database per service

Usually, yes:

DATABASE PER SERVICEDIAGRAM
User Service
└── User Database

Conversation Service
└── Conversation Database

File Service
└── File Storage

Model Gateway
└── Model Providers (external)

Not every service owns a database. The Model Gateway doesn't persist anything — it exists purely to abstract external AI providers, so the rest of the system never has to know which one you're using.

The Conversation Service shouldn't access the User Service's database directly:

AVOIDTYPESCRIPT
// Avoid
const user = await userDatabase.findById(userId);

The right way is to call the API:

CORRECTTYPESCRIPT
const response = await fetch(
  `http://user-service/users/${userId}`
);

This way, the user database remains the exclusive responsibility of the User Service.

SECTION 08

Microservices versus modular monolith

Microservices doesn't just mean splitting files:

NOT THISDIAGRAM
src/
├── users/
├── conversations/
├── files/
└── model-gateway/

That can still be a modular monolith.

Microservices are truly separate applications:

THIS IS ITDIAGRAM
projects/
├── user-service/
│   ├── package.json
│   ├── Dockerfile
│   └── src/
│
├── conversation-service/
│   ├── package.json
│   ├── Dockerfile
│   └── src/
│
├── file-service/
│   ├── package.json
│   ├── Dockerfile
│   └── src/
│
└── model-gateway/
    ├── package.json
    ├── Dockerfile
    └── src/

Each one can have:

  • its own server;
  • its own database;
  • its own repository;
  • its own Docker setup;
  • its own deploy;
  • a team responsible for it.

SECTION 09

Advantages, disadvantages, and when to use

ADVANTAGES

  • independent deploy per service;
  • independent scalability — only the service that needs it scales;
  • freedom to use different stacks per service;
  • an isolated failure doesn't necessarily bring down the whole system.

DISADVANTAGES

  • network communication adds latency and failure points;
  • data consistency gets harder, with no single transaction across databases;
  • more infrastructure to operate: multiple deploys, monitoring, distributed logs;
  • requires more DevOps and observability maturity.

A monolith is usually enough for small teams or early-stage products. Microservices make more sense when parts of the system — like a conversation engine that suddenly needs to scale, or a model gateway that needs to swap providers — need to scale, evolve, or be deployed differently from one another.

SECTION 10

Conclusion

Side-by-side comparison: Monolith has one codebase, one deploy, a shared database, is harder to scale, and has higher coupling. Microservices has multiple services with independent deploys, their own databases or storage, is easier to scale, and has lower coupling. Note: microservices are not always the answer — choose the right architecture for your context.
MICROSERVICES ARE NOT ALWAYS THE ANSWER

The main idea is:

SUMMARYTEXT
Monolith:
one large system with several responsibilities.

Microservices:
several small systems, each with one responsibility,
communicating through APIs or events.

THE END · THANKS FOR READING

Want to understand the full architecture?

The System Design article brings cache, queues, resilience, and observability together in one interactive guide.

READ SYSTEM DESIGN BACK TO CONTENTS