Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 025 / 100

Monolith first: the honest default in Engineering Craft

Most products should start as a well-structured monolith.

Expona Admin6 min read

Monolith first: the honest default. Most products should start as a well-structured monolith.

We're obsessed with microservices. We draw architecture diagrams with boxes and arrows. We talk about scaling teams and independent deployments. But here's the uncomfortable truth: most products should start as a well-structured monolith, and we should stop apologizing for it.

If you're building something new and the domain is still settling, a monolith isn't a compromise—it's the right call. Let me show you why, and how to do it properly.

One deployable, strict internal modules

When I say "monolith," I don't mean a big ball of mud where everything talks to everything else through global state and shared databases. I mean one deployable artifact with a strict internal module boundary.

The difference matters. A monolith can have clean architecture. You define clear modules—like orders, payments, inventory, shipping—and enforce that they communicate through well-defined interfaces, never by reaching into each other's database tables or internal functions.

Here's what this looks like in practice. I built an e-commerce platform that started as a monolith with NestJS. We had these modules:

typescript
// src/modules/orders/orders.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Order, OrderItem])],
  controllers: [OrdersController],
  providers: [OrdersService, OrderRepository],
  exports: [OrdersService], // Only expose the service
})
export class OrdersModule {}

// src/modules/payments/payments.module.ts
@Module({
  imports: [OrdersModule], // Depends on orders, not its DB
  controllers: [PaymentsController],
  providers: [PaymentsService, PaymentRepository],
})
export class PaymentsModule {
  constructor(private ordersService: OrdersService) {}
  
  async processPayment(paymentDto: CreatePaymentDto) {
    // Call orders through its public API
    const order = await this.ordersService.getById(paymentDto.orderId);
    // Never query orders DB directly
  }
}

Each module owns its database tables. No cross-module direct database queries. The payments module needs order data? It goes through OrdersService. This constraint feels restrictive at first, but it buys you something crucial: when you're ready to split into services later, you already have the boundaries drawn.

I've seen teams skip this discipline. They build a monolith where five different modules write to the same orders table, each with their own understanding of what a column means. When they try to extract a microservice, they're stuck. The boundaries are blurred.

Real transactions and simple debugging

One massive advantage of a monolith: transactions. Real, ACID transactions across your entire domain logic.

In a microservices setup, you're managing eventual consistency, saga patterns, compensating transactions, distributed tracing. These are hard problems. They're necessary sometimes, but they're hard.

In a monolith, you can wrap business operations in a single database transaction:

typescript
async createOrder(createOrderDto: CreateOrderDto): Promise {
  return await this.dataSource.transaction(async (manager) => {
    // All of this happens atomically
    const order = await manager.save(Order, {
      customerId: createOrderDto.customerId,
      status: 'pending',
      total: createOrderDto.total,
    });

    for (const item of createOrderDto.items) {
      const product = await manager.findOne(Product, {
        where: { id: item.productId },
        lock: { mode: 'pessimistic_write' }, // Prevent race conditions
      });

      if (product.stock < item.quantity) {
        throw new InsufficientStockError();
      }

      product.stock -= item.quantity;
      await manager.save(product);

      await manager.save(OrderItem, {
        orderId: order.id,
        productId: product.id,
        quantity: item.quantity,
      });
    }

    return order;
  });
}

Either the whole thing commits or nothing does. You don't wake up at 2 AM because a saga failed and left your system in an inconsistent state across three services.

Debugging is simpler too. A user reports an issue. You look at logs, add a breakpoint, step through code in one process. You're not correlating traces across ten different services, trying to figure out where the call chain broke. You've got one application, one database, one thread of execution to follow.

Faster delivery while the domain is still unclear

Here's the real insight: when you're starting a new product or feature, the domain is messy. You don't actually know how customers will use it. You don't know which features matter. You don't know where the real boundaries are.

Microservices require you to make those decisions upfront. You need to know: "Payments is its own service. Orders is its own service. These two talk over an API." If you're wrong, you've now got the overhead of service-to-service communication baked in, plus deployment coordination, contract versioning, and all the distributed complexity.

With a monolith, you can refactor module boundaries without touching deployment infrastructure. Something that looked like two separate concerns turns out to need constant cross-talk? Move it to one module. A module grew too big? Split it. You're changing function imports and dependency injection, not deploying new services.

I worked on a travel platform that started with a monolith. We thought itineraries and bookings would be cleanly separated. Turns out, changing an itinerary had complex cascading effects on bookings. With a monolith, we refactored the boundary in a day. With services, that would've been a week of API redesign and deployment testing.

Early speed isn't luck. It's because you're not fighting infrastructure decisions you made before you understood the problem.

Good modules become clean services later if needed

Here's where the long-term thinking comes in. If you've built your monolith with strict module boundaries, extracting a service is straightforward.

Let's say the payments module became genuinely independent—different scaling needs, separate deployment cadence, maybe you want to use a different tech stack. Your module boundary is already defined. Your inter-module communication goes through a known service interface. You already have a clear contract.

Extract it to a separate codebase and service. Replace the NestJS service import with an HTTP client:

typescript
// Before: same process
import { PaymentsService } from '@myapp/payments';

// After: separate service
class PaymentsServiceClient {
  async processPayment(paymentDto: CreatePaymentDto) {
    return await fetch('http://payments-service/api/payments', {
      method: 'POST',
      body: JSON.stringify(paymentDto),
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

The calling code doesn't change. The contracts stay the same. You've gone from function calls to HTTP calls, but the boundaries were already there. This is why the discipline matters in the beginning.

Most products never need to microservices. The monolith scales fine. But the ones that do? They extract cleanly because they were designed for it from day one.

Common mistakes

Mistake 1: Treating the monolith as a free pass to loose architecture. Teams say "we'll refactor later" and never do. The module boundaries become suggestions. By the time you need to split services, the code is entangled. Enforce the boundaries now. Use linters if you have to.

Mistake 2: Premature extraction to microservices. You split into services before your domain is stable. Now you're coordinating deployments, debugging distributed failures, and managing API versioning—all while still learning what your product actually is. Stay in the monolith longer.

Mistake 3: Shared databases across "service-like" modules in the monolith. If you have multiple modules writing to the same tables without going through service interfaces, you haven't bought the benefits of either architecture. Commit to the discipline.

Mistake 4: Overengineering the monolith setup. You don't need a sophisticated event bus or complex pub/sub in a monolith. Direct method calls between modules through well-defined services are fine. Save the complexity for when you actually have separate services.

Key takeaways

  • Build a monolith with strict internal module boundaries. One deployable, clean architecture inside.
  • Use ACID transactions freely while you can. That's a massive advantage over distributed systems.
  • Refactor module boundaries without infrastructure overhead while the domain is still settling.
  • Extract services only when you have a clear reason: different scaling needs, separate team, genuine independence. Not before.
  • If you built the module boundaries right, extraction is straightforward. If you didn't, it's a nightmare.
  • Stop feeling defensive about monoliths. They're the right call most of the time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles