← Back to list

From Monolith to Microservices, Part 1: The Architecture I Started With

How spice-me evolves from a modular monolith into a microservice architecture — one deliberate step at a time.

Ashikshuvo · 2026-07-08 11:51 · 0 claps · 7.1 min read
#microservices #monolithic-architecture #architecture-migration #nestjs #system-design-concepts
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

From Monolith to Microservices, Part 1: The Architecture I Started With

How spice-me evolves from a modular monolith into a microservice architecture — one deliberate step at a time.

Series roadmap

  • This article: the current state — one API, one database, many domains.
  • Next article: extracting the identity service and wiring it to the core app.

Introduction

When I set out to build spice-me, I had a clear product goal: a multi-restaurant platform where customers order and reserve tables, restaurant managers run the kitchen, and platform admins own the catalog.

I also had a clear architecture goal: ship fast, keep boundaries clean, and leave room to split services later — without paying the operational cost of microservices on day one.

This article is the first in a series about that evolution. Here I describe what exists today: a modular monolith in a TypeScript monorepo — not a ball of mud, and not yet a distributed system. Understanding this foundation matters, because every future extraction (starting with identity) builds on the module boundaries already drawn in the code.

The application in one paragraph

spice-me is a full-stack food ordering and operations platform. A Next.js frontend talks to a NestJS REST API backed by PostgreSQL. Three roles — platform admin, restaurant admin, and customer — share one backend. Catalog, restaurants, orders, kitchen workflows, tables, reservations, and analytics all live in the same deployable API today.

That’s intentional. The product needed to prove itself as a whole before the infrastructure split.

Why a monolith first

Microservices solve problems you often don’t have at the start: independent scaling per domain, separate team ownership, blast-radius isolation. They introduce problems you always have: distributed transactions, network failures, multiple databases, and harder local development.

I chose a modular monolith instead:

Approach What you get
Big ball of mud Fast at first, painful forever
Modular monolith ✅ One deploy, clear module boundaries, refactor-friendly
Microservices on day one Maximum ops overhead before product-market fit

NestJS encourages the middle path: one module per domain, explicit imports, services that export only what others need. The codebase is structured so a module can become a service later — but nothing is distributed until there’s a reason.

System overview today

Everything the browser needs flows through a single API and a single database.

Stack

| Layer | Technology |
|-------|------------|
| Frontend | Next.js 16, React 19, App Router, Tailwind v4 |
| Backend | NestJS (ESM), Prisma 7, PostgreSQL |
| Auth | Passport JWT — access (15m) + refresh (7d) |
| Tooling | Bun, Turborepo, TypeScript strict |
| API docs | Swagger at `/api/docs` |

Monorepo layout

The repository is a Turborepo workspace — not one giant folder, but not sixteen repositories either.

spice-me/ apps/ api/ ← NestJS backend (the monolith) web/ ← Next.js frontend packages/ typescript-config/ eslint-config/ tailwind-config/

Shared config lives in packages/. Application code stays in apps/. That separation already mirrors how you might share contracts between services later — without running multiple backends yet.

Inside the monolith: modular architecture

AppModule imports every feature module and contains no business logic. Each domain owns its folder:

apps/api/src/ auth/ ← login, JWT, guards users/ ← profiles, admin user CRUD restaurants/ categories/ products/ restaurant-products/ menu/ orders/ restaurant-tables/ ← tables + reservations platform-settings/ stats/ upload/ prisma/ ← global PrismaModule common/ ← shared utilities (e.g. VAT)

Every module follows the same shape:

<feature>/ <feature>.module.ts <feature>.controller.ts ← HTTP only <feature>.service.ts ← business logic dto/ ← validated inputs

Rule: Controller → Service → Prisma. No shortcuts.

How a request moves through the system

Whether the call is public (GET /menu) or protected (POST /orders), the path is the same:

  1. CORS and the global ValidationPipe run in main.ts.
  2. Guards validate JWT and role when the route requires it.
  3. The controller binds the body to a DTO.
  4. The service applies rules — sometimes calling another module’s exported service.
  5. Prisma executes SQL against the one database.

Services return profile objects (plain TypeScript types), not raw Prisma entities. That keeps HTTP responses stable even when the schema changes.

Module dependencies: who talks to whom

Most modules are self-contained: they inject PrismaService and touch only their own tables. Cross-module coupling is narrow on purpose — the graph is small enough to draw:

| Exported service | Used by |
|------------------|---------|
| `UsersService` | `AuthModule` |
| `ProductsService` | `MenuModule`, `RestaurantProductsModule`, `OrdersModule` |
| `PlatformSettingsService` | `ProductsModule`, `MenuModule`, `RestaurantProductsModule`, `OrdersModule` |
| `PrismaService` | All modules (global) |

Takeaway for the series: AuthModule + UsersModule form a natural identity boundary. ProductsModule + PlatformSettingsModule form a pricing boundary. Orders orchestrate both but don't own catalog or auth data. Those lines are where future service cuts will land.

Authentication and authorization (still in-process)

Today, identity isn’t a separate service. It’s two modules in the same process:

  • JWT payload: { sub, email, role } — attached to request.user as JwtUser.
  • Global role: ADMIN, USER, RESTAURANT_ADMIN — enforced by RolesGuard.
  • Restaurant scope: RestaurantAdminAssignment in the database — enforced in services like OrdersService and RestaurantScopeService.

Login, token refresh, and business APIs all hit the same host (localhost:3001). The frontend's api-client refreshes tokens against the same base URL. Simple — and exactly what we'll change first.

Data: one database, logical domains

There’s a single Prisma schema and one PostgreSQL instance. Logically, the data falls into domains that already match modules:

| Domain | Models | Module |
|--------|--------|--------|
| Identity | `User` | `auth`, `users` |
| Restaurants | `Restaurant`, `RestaurantAdminAssignment` | `restaurants` |
| Catalog | `Category`, `Product`, `Ingredient`, … | `products`, `categories`, … |
| Commerce | `Order`, `OrderItem` | `orders` |
| Operations | `RestaurantTable`, `TableReservation` | `restaurant-tables` |
| Platform | `PlatformCommonSettings` | `platform-settings` |

Foreign keys tie Order.userId to User.id today. That coupling is convenient in a monolith — and it's the main friction when identity moves to its own database. Part 2 of this series addresses that directly.

Frontend ↔ API: one backend URL

The web app doesn’t know about microservices yet. It speaks to one API:

  • NextAuth credentials provider → POST /auth/login
  • Business callsNEXT_PUBLIC_API_URL (default [http://localhost:3001)](http://localhost:3001))
  • Token refreshPOST /auth/refresh on the same base URL

Admin and customer UIs are route groups in Next.js ((admin), (app)). Authorization on the server uses the NextAuth session; authorization on the API uses JWT guards. Two layers, one identity source — for now.

What this architecture does well

After building the full product on this stack, a few properties stand out:

  • Fast local development. bun turbo dev runs web and API. One database. No service mesh, no port matrix.
  • Clear module seams. Sixteen feature modules, a small dependency graph, Swagger for every route. New work has an obvious home.
  • Transactional consistency. Placing an order can read products, apply VAT, and write snapshots in one database transaction. No sagas required.
  • Auth on the hot path is cheap. JWT validation is in-process. No network hop per request.
  • The monolith is honest about boundaries. Modules export services sparingly. That discipline is what makes a later split feasible — not wishful thinking.

Where the monolith starts to pinch

I’m not splitting for fashion. There are concrete pressures:

| Pressure | Why it matters |
|----------|----------------|
| **Identity is a distinct capability** | Auth + user CRUD is a cohesive domain; other services will need it without owning user tables |
| **`User` is a hub table** | Orders, reservations, and assignments all FK to `User` — fine in one DB, blocking across services |
| **Security surface** | Login endpoints share fate with order/catalog traffic on one deployable |
| **Team / scale (future)** | Identity could ship on a different cadence than catalog or orders |

These are the reasons the first extraction target is identity — not orders, not catalog. Smallest vertical slice that proves the pattern.

What comes next (Part 2)

The next article in this series covers isolating the identity service:

  • Extract AuthModule + UsersModule into apps/identity with its own database
  • Keep apps/api as the core business API (catalog, restaurants, orders, …)
  • Identity issues JWTs; core validates them locally (no per-request auth hop)
  • Service-to-service calls only when core needs user profile data
  • Frontend changes: login/refresh → identity; business APIs → core

The detailed migration plan — database split, JWT contract, UserLookupService, phased cutover, and frontend wiring — lives in the repo at docs/plans/identity-service-extraction.md. Part 2 will tell that story as a narrative walkthrough, not just a checklist.

Branch: work begins on micro-main.

Closing

spice-me today is a modular monolith done deliberately: one deploy, one database, sixteen domain modules, and a dependency graph small enough to reason about. That’s not a failure to adopt microservices — it’s the right first architecture for shipping a complex product with a small surface area of operational risk.

The journey to microservices doesn’t start by rewriting everything. It starts by recognizing which module boundary is already drawn in code — and cutting along that line. For spice-me, that line is identity.

Part 1 of the “From Monolith to Microservices” series · spice-me · Bun · Turborepo · NestJS · Next.js

If you’re building something similar and weighing monolith vs. microservices, I’d love to hear how you drew your own boundaries — drop a comment below.


메타데이터
post_id
a2b26e707aaa
slug
from-monolith-to-microservices-part-1-the-architecture-i-started-with-a2b26e707aaa
url
https://medium.com/@ashikshuvo1996/from-monolith-to-microservices-part-1-the-architecture-i-started-with-a2b26e707aaa
canonical_url
https://medium.com/@ashikshuvo1996/from-monolith-to-microservices-part-1-the-architecture-i-started-with-a2b26e707aaa
author_url
https://medium.com/@ashikshuvo1996
status
ok
fetched_at
2026-07-13 06:23:13