All Posts
December 15, 20244 min read

Building a POS System with NestJS: Architecture Decisions

How I designed and built a production Point of Sale system using NestJS, MongoDB, and Redis — covering real-time inventory, multi-payment handling, and the lessons learned.

NestJSArchitectureMongoDBPOS

Why Build a Custom POS?

When I started working on the MIC platform, the client needed more than a typical e-commerce store. They needed a unified system — online storefront and physical POS sharing the same inventory, the same customer database, and the same reporting dashboard.

Off-the-shelf solutions either couldn't handle the integration or cost more than building custom. So I built one.

The Architecture

The system follows a modular monolith pattern with NestJS:

// Each domain is a self-contained NestJS module
@Module({
  imports: [
    InventoryModule,
    PaymentModule,
    CustomerModule,
    ReportingModule,
    NotificationModule,
  ],
})
export class AppModule {}

I chose a modular monolith over microservices for a few reasons:

  1. Team size — I was the sole developer. Microservices add operational overhead that doesn't pay off at this scale.
  2. Shared transactions — POS operations (sale, inventory decrement, receipt generation) need to be atomic. Cross-service transactions are painful.
  3. Deployment simplicity — One container, one deploy pipeline, one set of logs.

Real-Time Inventory Sync

The hardest problem was keeping inventory consistent between the online store and multiple POS terminals. A customer buying the last item online while a cashier is ringing up the same item in-store is a recipe for overselling.

@Injectable()
export class InventoryService {
  constructor(
    private readonly redis: RedisService,
    private readonly inventoryRepo: InventoryRepository,
  ) {}

  async reserveStock(productId: string, quantity: number): Promise<boolean> {
    // Use Redis DECR for atomic stock reservation
    const key = `stock:${productId}`;
    const remaining = await this.redis.decrby(key, quantity);

    if (remaining < 0) {
      // Rollback — not enough stock
      await this.redis.incrby(key, quantity);
      return false;
    }

    // Persist to MongoDB asynchronously
    await this.inventoryRepo.decrementStock(productId, quantity);
    return true;
  }
}

Redis gives us atomic decrements — no race conditions. The MongoDB write happens after, serving as the durable store. If it fails, a reconciliation job catches the drift.

Multi-Payment Support

A single POS transaction might split across cash, card, and store credit. The payment module handles this with a strategy pattern:

interface PaymentStrategy {
  process(amount: number, metadata: PaymentMetadata): Promise<PaymentResult>;
  refund(transactionId: string, amount: number): Promise<RefundResult>;
}

@Injectable()
export class PaymentService {
  private strategies: Map<PaymentMethod, PaymentStrategy>;

  async processTransaction(items: CartItem[], payments: PaymentSplit[]) {
    // Validate total matches
    const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    const paid = payments.reduce((sum, p) => sum + p.amount, 0);

    if (Math.abs(total - paid) > 0.01) {
      throw new BadRequestException('Payment total does not match cart total');
    }

    // Process each payment method
    const results = await Promise.all(
      payments.map((p) => this.strategies.get(p.method)!.process(p.amount, p.metadata)),
    );

    return results;
  }
}

Lessons Learned

  1. Start with the receipt — Designing the receipt format first forced me to think through every edge case in a transaction (discounts, taxes, split payments, returns).

  2. Redis is your friend — For anything that needs to be fast and atomic (stock counts, session management, rate limiting), Redis is the answer.

  3. Modular monolith scales further than you think — We hit 200+ daily users without needing to split into services. The module boundaries are clean enough that we could split later if needed.

  4. Barcode scanning is deceptively complex — Different barcode formats, scanner quirks, and the UX of rapid sequential scans required more iteration than expected.

What I'd Do Differently

If starting over, I'd add event sourcing for transactions from day one. Being able to replay and audit every state change in a financial system is invaluable. We bolted it on later, but it would have been cleaner as a foundation.

The POS system now handles all of MIC's retail operations reliably. The modular architecture means adding features (loyalty program, supplier management) is straightforward — each gets its own module with clean interfaces to the rest.