Clear Boundaries: The Architecture Principle That Lets You Ship Faster

architectureprinciplesdomain-driven
Clear Boundaries: The Architecture Principle That Lets You Ship Faster

The problem with tangled code

Early in a project, it feels faster to put everything in one place. Auth logic reaches into billing. Billing queries the games table directly. Analytics reads from every collection. Before you know it, changing a subscription flow requires touching files across six directories, and nobody is confident the deploy will not break something unrelated.

I have seen this pattern in multiple projects. The cost is not obvious at first, but it compounds: slower onboarding, riskier deploys, and teams that step on each other constantly.

The fix is not a complex framework. It is a structural decision: each business area owns its own code, data, and rules.

What clear boundaries look like in practice

In Cliencer, a SaaS platform I built, the backend is organized around business domains. Not around technical layers like "controllers", "services", and "models" in flat directories. Each domain is a self-contained module:

back-cliencer/src/app/
  analytics/       # Event tracking and reporting
  auth/            # Authentication and login
  billing/         # Stripe integration, subscriptions, payments
  business/        # Business entity management
  challenges/      # Challenge content
  clients/         # Client user management
  crm/             # Email campaigns, WhatsApp, onboarding messages
  games/           # Game logic and configuration
  webhooks/        # External service integrations
  ...

Each module has the same internal structure: an API class (the public interface), a controller (HTTP layer), routes, domain types, and a repository. The critical part is what sits between modules: the API class acts as the contract boundary.

Anatomy of a module

Take the billing module. Its internal structure tells you everything about what it owns and how you interact with it:

billing/
  BillingApi.ts          # Public interface -- other modules call this
  controller.ts          # HTTP handlers -- external clients call this
  routes.ts              # Route definitions
  domain/
    index.ts             # Domain types (interfaces, enums)
    planDisplayNames.ts  # Business logic specific to billing
  stripeService/
    StripeService.ts     # Third-party integration (encapsulated)
    interfaces.ts        # Stripe-specific types
  schemas/               # Request validation
  __tests__/             # Module tests

The BillingApi is the only class other modules should import. It exposes operations like createCheckoutSession, getSubscriptionInfo, and getBillingHistory. The Stripe integration sits behind it -- nobody else in the system knows or cares that we use Stripe.

export class BillingApi {
  private stripeService: StripeService;
  private businessApi: BusinessApi;
  private clientApi: ClientApi;
  private crmApi: CrmApi;
 
  async createCheckoutSession(
    params: CreateCheckoutSessionParams
  ): Promise<CheckoutSessionResult> {
    // All Stripe complexity lives here
  }
 
  async getSubscriptionInfo(
    business: IBusiness,
    stripeCustomerId: string | null
  ): Promise<ISubscriptionInfoResponse> {
    // Combines local plan data with Stripe status
  }
}

When the billing module needs business data, it asks through BusinessApi. It never queries the business collection directly. This is the core discipline: communicate through public interfaces, not through shared database access.

Each domain defines its own types

The billing module defines its own domain types that represent billing concepts:

// billing/domain/index.ts
export interface ISubscriptionInfoResponse {
  plan: {
    name: string;
    displayName: string;
    status: "active" | "inactive" | "expired" | "trial";
    endDate: string;
    daysRemaining: number | null;
  };
  billing: {
    hasPaymentMethod: boolean;
    stripeCustomerId: string | null;
  };
}
 
export type StripeSubscriptionStatus =
  | "incomplete"
  | "incomplete_expired"
  | "trialing"
  | "active"
  | "past_due"
  | "canceled"
  | "unpaid"
  | "paused"
  | "none";

The analytics module has its own, completely separate type hierarchy:

// analytics/domain/types.ts
export type BusinessEventType =
  | "business_created"
  | "game_activated"
  | "payment_succeeded"
  | "image_created"
  | "owner_logged_in"
  | "first_game_created"
  | ...;
 
export type InfluencerEventType =
  | "game_visited"
  | "game_completed"
  | "coupon_generated"
  | "coupon_redeemed";
 
export interface EventQueryFilters {
  businessId: string;   // Required -- multi-tenant isolation
  type?: EventType | EventType[];
  gameId?: string;
  from?: Date;
  to?: Date;
}

Notice how analytics even separates its own internal concerns: business events and influencer events live in different repositories with different schemas. The AnalyticsApi class provides a unified interface while keeping the underlying data separated:

export class AnalyticsApi {
  private businessRepo: BusinessEventsRepository;
  private influencerRepo: InfluencerEventsRepository;
 
  async trackBusinessEvent(event): Promise<string> {
    return this.businessRepo.save(event);
  }
 
  async trackInfluencerEvent(event): Promise<string> {
    return this.influencerRepo.save(event);
  }
 
  async getCombinedTimeline(businessId, options): Promise<AnalyticsEvent[]> {
    const [businessEvents, influencerEvents] = await Promise.all([
      this.businessRepo.getTimeline(businessId, options),
      this.influencerRepo.getTimeline(businessId, options),
    ]);
    return [...businessEvents, ...influencerEvents]
      .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
  }
}

Two separate data stores, one clean API. The caller never needs to know about the internal split.

Routes reinforce the boundaries

The route registration makes the boundary explicit at the HTTP level too. Each module owns its route prefix, and the main router simply composes them:

// server/registerRoutes.ts
export const registerRoutes = (router: Router) => {
  authRoutes(router);
  clientRoutes(router);
  businessRoutes(router);
  billingRoutes(router);
  challengeRoutes(router);
  gameRoutes(router);
  webhookRoutes(router);
  // ...each module registers its own routes
};

Within each module, routes are scoped to their domain. Billing routes all live under /billing/* or /:businessId/billing/*. Games under /games/*. No cross-contamination.

Cross-module communication goes through APIs

When the billing controller needs to know about a business, it does not import the business repository. It uses the public API:

export class BillingController {
  private billingApi: BillingApi;
  private clientApi: ClientApi;
  private businessApi: BusinessApi;
 
  async getSubscriptionInfo(req, res, next) {
    const business = await this.businessApi.getBusinessById(businessId);
    const subscriptionInfo = await this.billingApi.getSubscriptionInfo(
      business, stripeCustomerId
    );
    res.json({ success: true, data: subscriptionInfo });
  }
}

When the business module needs to track an analytics event, it asks through AnalyticsApi:

// Inside BusinessApi.createNewBusiness()
const analyticsApi = new AnalyticsApi();
await analyticsApi.trackBusinessEvent({
  type: "business_created",
  businessId: business._id,
  clientId: ownerUserId,
  payload: { businessName: business.name, plan: business.plan.name },
});

The business module does not know what collection analytics uses, what indexes it has, or how events are structured internally. It only knows the public interface.

Why this matters for the business

This is not architectural purism. Clear boundaries produce three concrete business outcomes:

Independent evolution. When I needed to add smart checkout flow with subscription status detection, the changes were entirely within the billing module. Business, analytics, and games were untouched. The deploy risk was contained.

Safer onboarding. A new developer working on game logic never needs to understand Stripe webhooks. The surface area they need to learn is bounded by the module they are working in.

Parallel work. Two developers can work on billing and analytics simultaneously without merge conflicts, because the modules only share interfaces, not implementations.

The practical takeaway

You do not need a microservices architecture to get clear boundaries. A well-structured monolith with module-level APIs achieves the same isolation with far less operational overhead.

The rule is simple: every module exposes a public API class. Other modules import that class and nothing else. Domain types are defined locally. Repositories are private. Third-party integrations are encapsulated.

Start by drawing the boundaries around your business domains -- not around technical layers. Then enforce them through import discipline. The result is a codebase where each area can change independently, deploy safely, and scale its team without coordination overhead.

About me

Written by Fran Llantada โ€” full-stack developer at Nieve Consulting. In my spare time I built Cliencer, a complete SaaS from scratch on my own. These articles are the engineering lessons I picked up along the way.