This content originally appeared on Level Up Coding - Medium and was authored by Robin Viktorsson
Software architecture is much more than simply organizing code — it fundamentally influences how your system scales under load, adapts to changing requirements, and communicates internally and externally. Choosing the right architecture early on can save countless hours of refactoring and technical debt down the road. One of the most important — and often debated — decisions in modern application development is whether to build a monolith or embrace microservices.
In this guide, we take a deep dive into both architectural models, using practical examples in Node.js with TypeScript to illustrate key concepts. We’ll break down the core principles, advantages, and drawbacks of each approach, helping you understand how they affect development speed, team collaboration, deployment complexity, and scalability. By the end, you’ll have a clear insight to evaluate which architecture fits your project’s goals, team size, and domain complexity — empowering you to make an informed decision that balances short-term needs with long-term maintainability.
What Is a Monolith? 🧱
A monolithic architecture organizes an application as a single unified codebase. All business logic is developed, deployed, and scaled together as one cohesive unit. It’s often the default starting point for many projects.
Philosophy & Origin: Monolithic architecture is rooted in the early days of software development when applications were simpler and smaller in scope. The idea was to build a complete system in one place, emphasizing simplicity, tight integration, and ease of understanding. It reflects a holistic approach where all parts are interconnected and evolve together, much like a single organism. The philosophy is “one codebase, one deployable,” which works well when the system is small and teams are limited in size.
Advantages: Monoliths are simple to build and maintain in the early stages. With everything in one place, you benefit from straightforward development workflows, easy testing, and faster onboarding for new developers. There’s no need to manage complex inter-service communication, and local development is usually just a matter of spinning up a single server and database.
Disadvantages: However, this simplicity comes at a cost as the application grows. A tightly coupled codebase becomes harder to maintain, refactor, or scale independently. A small change in one module can inadvertently break other parts of the system. Deployments also become riskier, and even minor bugs can take down the entire application. While scaling a monolith is possible, it often means scaling parts of the system that don’t need it, leading to inefficient resource usage.
What Are Microservices? 🧩
A microservices architecture breaks down the application into a collection of small, autonomous services — each responsible for a distinct business capability. Instead of one massive codebase, you’ll have separate services like auth, posts, and comments, each running independently, communicating over HTTP, gRPC, or message queues, etc.
Philosophy & Origin: Microservices emerged from the limitations experienced by large monolithic applications, especially at scale and in complex domains. The microservices philosophy is modularity, decentralization, and independence. Inspired by domain-driven design and agile development principles, microservices embrace the idea that a system should be a composition of loosely coupled, independently deployable services. Each service is owned by a small team focusing on a specific business capability, promoting autonomy and faster iteration. This architecture treats the system more like a distributed ecosystem rather than a single organism. It became popular alongside the rise of cloud computing and containerization, which made managing many small services feasible.
Advantages: The biggest advantage of microservices is flexibility. You can deploy, scale, and update services independently. Teams can own specific domains and work in parallel, reducing merge conflicts and improving release velocity. Microservices also allow you to optimize tech choices for each service — for example, using TypeScript in one and Go in another.
Disadvantages: On the flip side, microservices introduce a significant amount of operational complexity. You’ll need to handle inter-service communication, versioning, distributed logging, failure recovery, service discovery, and more. Debugging issues that span multiple services can be challenging. Local development is no longer a simple npm run dev — it often requires running multiple services or relying on e.g. Docker Compose.
Despite the overhead, microservices shine in large, mature systems where scalability, modularity, and domain separation are essential.
When to Choose What? 🤔
Choosing between a monolith and microservices depends on multiple factors: team size, project maturity, deployment complexity, scaling needs, and domain boundaries. There’s no universal answer, but here’s a practical breakdown to help guide your decision.
Choose a Monolith When:
- You’re just starting out: In early-stage projects or MVPs, speed and simplicity matter most. A monolith allows you to focus on building features rather than managing infrastructure.
- Your team is small: With fewer developers, it’s easier to collaborate in a single codebase without managing APIs, service boundaries, or multi-repo coordination.
- You want to iterate quickly: A monolith supports rapid prototyping and short feedback loops. Local development is easier without needing service orchestration.
- Your domain is not yet fully understood: When requirements are still evolving, it’s easier to refactor within a monolith than to redesign multiple services.
- You want to avoid DevOps complexity early on: While both architectures need CI/CD and monitoring, a monolith is simpler to manage initially — one codebase, one pipeline, one deploy. Microservices add complexity with multiple pipelines, service discovery, distributed tracing, and more.
Choose Microservices When:
- Your application is growing rapidly: As teams and features scale, a monolith becomes a bottleneck. Microservices allow teams to work independently.
- You need to scale parts of the system differently: In a monolith, scaling often means duplicating the whole app, even if only one part needs more resources. Microservices let you scale and optimize individual services based on demand — for instance, running CPU-intensive services on high-performance machines, while lightweight services use cheaper ones.
- You have clear domain boundaries: If your system naturally divides into distinct contexts (e.g., Auth, Payments, Search), microservices help isolate responsibilities.
- You want language or technology flexibility: Microservices let you use the right tool for the job — Node.js for your API, Rust for performance-critical services, etc.
- You need high availability and fault tolerance: A bug in one microservice shouldn’t bring down the entire system. Microservices enable isolation and graceful degradation.
- You have a strong DevOps foundation: If your team is experienced with Docker, Kubernetes, observability, and CI/CD pipelines, you’re better positioned to handle the operational complexity of microservices.
Can Monolith be Used for Large Projects? 🏗️
Yes, a monolithic architecture can be used for large projects, and many successful systems have been built this way. However, it requires careful planning and discipline to avoid the pitfalls commonly associated with large monoliths.
For large projects, a monolith can still provide benefits like simpler local development, consistent tooling, and easier debugging since everything lives in one place. But as the codebase grows, maintaining clear modular boundaries within the monolith becomes crucial. Techniques such as modularization, layered architecture, and strict separation of concerns help keep the codebase manageable, even when it scales.
Many teams adopt a modular monolith approach: they organize the system into well-defined internal modules or packages that interact through explicit interfaces. This internal decoupling can ease maintainability, testing, and parallel development across teams while still deploying a single application.
Scaling a large monolith typically involves vertical scaling (adding more resources to the server) or deploying multiple instances behind load balancers. While this approach can handle significant load, it may become less efficient compared to microservices when different parts of the app have vastly different scaling needs.
Hands-On Practical Examples in NodeJS and TypeScript⚒️
The real power of choosing between monolith and microservices shines through when you build something tangible. In this section, we’ll create a simple blog platform backend where users can register, log in, create posts, and add comments. Let’s explore how this same functionality can be structured differently using a monolithic approach versus a microservices architecture.
Note: Keep in mind, these examples are meant purely to demonstrate the architectural differences — they’re not intended as production-ready implementations.
1. Monolith Example
In a monolithic architecture, all features of the application live together inside a single Express application. This means you have one unified codebase, shared models, and a single database. Everything is tightly coupled and runs as one cohesive service.
Project Structure 📁
/monolith-blog
├── node_modules/
├── src/
│ ├── app.ts # Main entry point for the app
│ ├── routes/ # API route handlers grouped by feature
│ │ ├── auth.ts # Authentication routes (register, login)
│ │ ├── posts.ts # Post creation and retrieval routes
│ │ └── comments.ts # Comment management routes
│ ├── models/ # Data models and database interaction logic
│ │ ├── user.model.ts
│ │ ├── post.model.ts
│ │ └── comment.model.ts
│ └── db.ts # Database initialization and setup
├── package.json
├── package-lock.json
└── tsconfig.json
Installing Dependencies 🧱
Initialize your project and install dependencies:
npm init -y
npm install express better-sqlite3
npm install -D typescript @types/express @types/better-sqlite3 ts-node nodemon
npx tsc --init
Add a development script to your package.json for easier local development:
"scripts": {
"dev": "nodemon --watch src -e ts --exec ts-node src/app.ts"
}
Database Setup 🛢️
We use better-sqlite3 to quickly spin up a lightweight SQLite database. On app startup, three tables are created if they don't exist: users, posts, and comments.
// src/db.ts
import Database from 'better-sqlite3';
const db = new Database('blog.db');
// Run once on startup
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER,
title TEXT NOT NULL,
content TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
postId INTEGER,
userId INTEGER,
content TEXT NOT NULL
);
`);
export default db;
User Model 👤
The user model encapsulates logic to create a new user by inserting into the users table. It returns the inserted user’s ID and email.
// src/models/user.model.ts
import db from '../db';
export function createUser(email: string, password: string) {
const stmt = db.prepare('INSERT INTO users (email, password) VALUES (?, ?)');
const result = stmt.run(email, password);
return { id: result.lastInsertRowid, email };
}
Post Model ✍️
Handles inserting a new blog post into the posts table. The userId associates the post with its creator.
// src/models/post.model.ts
import db from '../db';
export function createPost(userId: number, title: string, content: string) {
const stmt = db.prepare('INSERT INTO posts (userId, title, content) VALUES (?, ?, ?)');
const result = stmt.run(userId, title, content);
return { id: result.lastInsertRowid, userId, title, content };
}
Comment Model 💬
Creates a comment linked to a specific post and user.
// src/models/post.model.ts
import db from '../db';
export function createComment(userId: number, postId: number, content: string) {
const stmt = db.prepare('INSERT INTO comments (userId, postId, content) VALUES (?, ?, ?)');
const result = stmt.run(userId, postId, content);
return { id: result.lastInsertRowid, userId, postId, content };
}
API — User Registration Route 🔐
Defines a route to handle user registration by accepting email and password, then storing the user in the database.
// src/routes/auth.ts
import { Router } from 'express';
import { createUser } from '../models/user.model';
const router = Router();
router.post('/register', (req, res) => {
const { email, password } = req.body;
const user = createUser(email, password);
res.status(201).json(user);
});
export default router;
API — Post Creation Route 🌐
Handles new post creation by accepting userId, title, and content
// src/routes/posts.ts
import { Router } from 'express';
import { createPost } from '../models/post.model';
const router = Router();
router.post('/', (req, res) => {
const { userId, title, content } = req.body;
const post = createPost(userId, title, content);
res.status(201).json(post);
});
export default router;
API — Comment Creation Route 🌐
Accepts data to create a comment linked to a post and user.
// src/routes/comment.ts
import { Router } from 'express';
import { createComment } from '../models/comment.model';
const router = Router();
router.post('/', (req, res) => {
const { userId, postId, content } = req.body;
const comment = createComment(userId, postId, content);
res.status(201).json(comment);
});
export default router;
Entry Point 🏁
This is the main file bootstrapping the entire monolithic app. It sets up Express, middleware for JSON parsing, and mounts the route handlers.
// src/app.ts
import express from 'express';
import authRoutes from './routes/auth';
import postRoutes from './routes/posts';
import commentRoutes from './routes/comments';
const app = express();
app.use(express.json());
app.use('/auth', authRoutes);
app.use('/posts', postRoutes);
app.use('/comments', commentRoutes);
app.listen(3000, () => {
console.log('Monolith server running at http://localhost:3000');
});
We now have a fully working monolith blog backend in TypeScript.
Running the Monolith Project ▶️
Open your terminal and run:
npm run dev
This starts the server with hot reload support via nodemon and ts-node, making it easy to develop and test your monolith.
2. Microservices Example
We’ll now build the same functionality using microservices. Each service has its own server and database.
Project Structure 📁
microservices-blog/
├── auth-service/
│ ├── node_modules/
│ ├── src/
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── package.json
│ └── package-lock.json
├── posts-service/
│ ├── node_modules/
│ ├── src/
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── package.json
│ └── package-lock.json
├── comments-service/
│ ├── node_modules/
│ ├── src/
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── package.json
│ └── package-lock.json
└── api-gateway/
├── node_modules/
├── src/
│ └── index.ts
├── tsconfig.json
├── package.json
└── package-lock.json
Note: Avoid installing all dependencies in a single root node_modules folder unless you use a monorepo tool such as Nx, Lerna, or TurboRepo. Without these tools, each service should manage its own dependencies to ensure independence and avoid bloated deployments.
Installing Dependencies 🧱
Each microservice (and the API Gateway) is an independent Node.js project. You must initialize and install dependencies separately inside each folder to keep services isolated and manageable.
Run these commands inside each folder:
## Auth service
cd auth-service
npm init -y
npm install express better-sqlite3
npm install -D typescript @types/express @types/better-sqlite3 ts-node nodemon
npx tsc --init
## Posts service
cd ../posts-service
npm init -y
npm install express better-sqlite3
npm install -D typescript @types/express @types/better-sqlite3 ts-node nodemon
npx tsc --init
## Comments service
cd ../comments-service
npm init -y
npm install express better-sqlite3
npm install -D typescript @types/express @types/better-sqlite3 ts-node nodemon
npx tsc --init
## API gateway
cd ../api-gateway
npm init -y
npm install express http-proxy-middleware
npm install -D typescript @types/express ts-node nodemon
npx tsc --init
Update package.json Scripts
Add the following script to each service’s package.json to enable quick development mode with automatic TypeScript execution:
"scripts": {
"dev": "nodemon --watch src -e ts --exec ts-node src/index.ts"
}
Auth Service 🔐
This microservice handles user registration. It maintains its own SQLite database (auth.db) and user table.
// auth-service/src/index.ts
import express from 'express';
import Database from 'better-sqlite3';
const db = new Database('auth.db');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
password TEXT NOT NULL
);
`);
const app = express();
app.use(express.json());
app.post('/register', (req, res) => {
const { email, password } = req.body;
const stmt = db.prepare('INSERT INTO users (email, password) VALUES (?, ?)');
const result = stmt.run(email, password);
res.status(201).json({ id: result.lastInsertRowid, email });
});
app.listen(3001, () => console.log('Auth service running on http://localhost:3001'));
Posts Service ✍️
This service is responsible for creating and storing blog posts in its own SQLite database (posts.db).
// posts-service/src/index.ts
import express from 'express';
import Database from 'better-sqlite3';
const db = new Database('posts.db');
db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL
);
`);
const app = express();
app.use(express.json());
app.post('/', (req, res) => {
const { userId, title, content } = req.body;
const stmt = db.prepare('INSERT INTO posts (userId, title, content) VALUES (?, ?, ?)');
const result = stmt.run(userId, title, content);
res.status(201).json({ id: result.lastInsertRowid, userId, title, content });
});
app.listen(3002, () => console.log('Posts service running on http://localhost:3002'));
Comments Service 💬
Manages user comments on posts, maintaining its own SQLite database (comments.db).
// comments-service/src/index.ts
import express from 'express';
import Database from 'better-sqlite3';
const db = new Database('comments.db');
db.exec(`
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER NOT NULL,
postId INTEGER NOT NULL,
content TEXT NOT NULL
);
`);
const app = express();
app.use(express.json());
app.post('/', (req, res) => {
const { userId, postId, content } = req.body;
const stmt = db.prepare('INSERT INTO comments (userId, postId, content) VALUES (?, ?, ?)');
const result = stmt.run(userId, postId, content);
res.status(201).json({ id: result.lastInsertRowid, userId, postId, content });
});
app.listen(3003, () => console.log('Comments service running on http://localhost:3003'));
API Gateway 🌐
The API Gateway serves as the single entry point for client requests. It proxies requests to appropriate microservices based on the URL path, enabling a unified API surface.
// api-gateway/src/index.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
app.use('/auth', createProxyMiddleware({ target: 'http://localhost:3001', changeOrigin: true }));
app.use('/posts', createProxyMiddleware({ target: 'http://localhost:3002', changeOrigin: true }));
app.use('/comments', createProxyMiddleware({ target: 'http://localhost:3003', changeOrigin: true }));
app.listen(3000, () => {
console.log('API Gateway running on http://localhost:3000');
});
Running the Microservices ▶️
Each service runs independently on its own port. You need to launch four terminals and start each service individually:
# Terminal 1 - Auth Service
cd auth-service
npm run dev
# Terminal 2 - Posts Service
cd posts-service
npm run dev
# Terminal 3 - Comments Service
cd comments-service
npm run dev
# Terminal 4 - API Gateway
cd api-gateway
npm run dev
Once all services are running, your API Gateway will be accessible at http://localhost:3000. It routes requests to the respective microservices transparently, allowing your frontend or clients to interact with a single API endpoint.
Testing the Applications 🔍
After implementing the blog backend using either the Monolith or Microservices approach, it’s time to validate that everything is working as expected. We’ll do this by sending HTTP requests through the main server entry point (http://localhost:3000) and observing the responses. These tests simulate real-world usage from a frontend or mobile client.
Regardless of the architecture, the goal is the same: ensure that users can register, create posts, and comment successfully.
1. Registering a New User
User registration is the first step in interacting with the system. Regardless of whether it’s a monolith or a set of services behind an API Gateway, the endpoint for creating a new user is the same. We send a POST request to the /users endpoint, passing in an email and password:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d "{\"email\":\"robin@example.com\", \"password\":\"secret\"}"
If the registration is successful, both architectures respond with a newly created user object:
{
"id": 1,
"email": "robin@example.com"
}
In the monolith, this goes through a direct route to the controller and model. In microservices, the API Gateway forwards the request to the auth service, which handles user creation independently.
2. Creating a Blog Post
Once registered, the user can create a new blog post. This is done by sending a POST request to the /posts endpoint, including the title, content, and the user’s ID.
curl -X POST http://localhost:3000/posts -H "Content-Type: application/json" -d "{\"userId\":1,\"title\":\"My First Post\",\"content\":\"Hello World\"}"
If everything works correctly, the response will contain the newly created post:
{
"id": 1,
"userId": 1,
"title": "My First Post",
"content": "Hello World"
}
In the monolith, the controller uses the shared SQLite database to insert this post directly. In the microservices setup, the posts service stores the post in its own isolated database, trusting that the provided userId is valid—typically assumed to have been verified upstream or at the API Gateway level.
3. Adding a Comment
Finally, we test adding a comment to the blog post. This step checks whether users can engage with content, linking a comment to both a user and a post.
curl -X POST http://localhost:3000/comments -H "Content-Type: application/json" -d "{\"userId\":1,\"postId\":1,\"content\":\"Nice post!\"}"
The response should confirm that the comment was saved:
{
"id": 1,
"userId": 1,
"postId": 1,
"content": "Nice post!"
}
In the monolithic app, this is handled via a route that interacts with the shared database, validating and linking the comment in a single process. In microservices, the comments service independently saves the comment to its own data store, using the userId and postId as references passed in the request body.
Conclusion 📣
Deciding between a monolithic and microservices architecture isn’t about picking the “better” one — it’s about choosing the approach that aligns best with your current context and long-term vision.
Monoliths offer simplicity, rapid development, and minimal operational overhead, making them ideal for startups, MVPs, or teams still shaping their domain. They let you move fast with fewer moving parts.
Microservices, on the other hand, shine in complexity. They empower teams to scale independently, build modular systems, and isolate faults effectively — but come with the cost of higher operational complexity and a steeper learning curve.
The key takeaway is this: start simple, but architect with growth in mind. A well-structured monolith today can evolve into a modular monolith or even be split into microservices later when the benefits truly outweigh the trade-offs. The examples in this guide demonstrate how the same functionality can live under one roof or span across distributed services — and how that choice impacts code structure, deployment, and scalability.
Whether you’re building your first Node.js app or refactoring a growing TypeScript system, the goal is the same: deliver value without getting trapped by your architecture. Choose what scales with you — not just technically, but also in terms of team, tooling, and vision.
🙏 Thanks for reading to the end! If you have any questions, feel free to drop a comment below.
If you enjoyed this article, follow me on medium or social media — I’d love to connect and follow you back:
- Robin Viktorsson (@RobinViktorsson) / X
- Robin Viktorsson (@robinviktorsson@mastodon.social) — Mastodon
Monolith vs Microservices: A Practical Guide with Examples in Node.js using TypeScript was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Robin Viktorsson

Robin Viktorsson | Sciencx (2025-07-14T01:24:13+00:00) Monolith vs Microservices: A Practical Guide with Examples in Node.js using TypeScript. Retrieved from https://www.scien.cx/2025/07/14/monolith-vs-microservices-a-practical-guide-with-examples-in-node-js-using-typescript/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.