Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 027 / 100

Find service boundaries in the business, explained simply

Split by business capability, not by technical layer.

Expona Admin8 min read

Find service boundaries in the business

Most teams split their architecture by what they see in the database or the API layer—a service that talks to users, a service that talks to data, a service that handles logic. But this almost always creates friction. When you find service boundaries in the business instead, you unlock teams that can move independently and systems that don't need constant cross-service coordination.

Why technical layers fail as boundaries

I've watched this pattern play out dozens of times. A team builds a "database service" that owns all the queries, a "business logic service" that orchestrates workflows, and a "presentation service" that serves HTTP requests. On day one, it feels clean. By week three, every feature requires changes across all three services. By month two, the team is tangled in race conditions and deployment coordination nightmares.

The problem is simple: technical layers don't own anything end-to-end. They're infrastructure abstractions, not boundaries. When your services are built this way, you're not really decoupling—you're just distributing the same monolith across the network.

Business capabilities, on the other hand, own a complete slice of value. An order isn't just a database row or a business rule. It's something a customer creates, that billing processes, that fulfillment ships, that support tracks. When you organize services around these real-world concepts, each service can evolve independently because each one solves a distinct business problem.

Orders, billing, catalog—not 'database service'

Let me show you what this looks like in practice. Imagine you're building an e-commerce platform. The temptation is to create services like this:

  • ProductService (serves product data)
  • UserService (serves user data)
  • OrderService (orchestrates orders)
  • DatabaseService (owns all persistence)

Instead, think about what your business actually does:

  • Catalog service: Owns products, categories, pricing, inventory levels. Someone browsing products talks to this service.
  • Orders service: Owns order creation, order state, order history. When a customer checks out, this service is responsible.
  • Billing service: Owns invoices, payments, refunds, subscription state. Finance and payment systems integrate here.
  • Fulfillment service: Owns shipment state, tracking, returns. Warehouse and logistics integrate here.

Notice the difference. Each service owns a complete business capability, not a technical layer. The Catalog service doesn't just store products—it understands what a product is, how it's priced, and how much stock exists. The Orders service doesn't just run queries—it knows what state an order can be in, what transitions are valid, and what events matter to the business.

This matters because a feature request like "add a discount for bulk orders" now lands clearly in Orders service. It doesn't scatter across three different services. The Orders team can discuss the business rule, implement it, test it, and deploy it without coordinating with five other teams.

Each boundary owns its data

The most important rule: each service owns its data. This is non-negotiable. If two services share a database or constantly read each other's tables, you haven't built service boundaries at all—you've just added network calls to a monolith.

Here's what this looks like wrong:

typescript

// Don't do this
class OrderService {
  async getOrderWithProduct(orderId: string) {
    // Orders service reaches directly into catalog database
    const order = await this.ordersDb.query(
      'SELECT * FROM orders WHERE id = ?', orderId
    );
    const product = await this.catalogDb.query(
      'SELECT * FROM products WHERE id = ?', order.productId
    );
    return { order, product };
  }
}

Why this fails: Catalog changes its schema, and Orders breaks. Catalog gets slow, and Orders times out. You've created a hard dependency that looks distributed but behaves like a monolith.

Here's what it looks like right:

typescript

// Orders owns order data. If it needs product info, it calls the Catalog service.
class OrderService {
  constructor(private catalogService: CatalogClient) {}

  async getOrderWithProduct(orderId: string) {
    const order = await this.ordersDb.query(
      'SELECT * FROM orders WHERE id = ?', orderId
    );
    
    // Call Catalog service as a client. Expect it might fail or be slow.
    try {
      const product = await this.catalogService.getProduct(
        order.productId,
        { timeout: 1000 }
      );
      return { order, product };
    } catch (error) {
      // Catalog is down. Return order without product info, or cached data.
      return { order, product: null };
    }
  }
}

Now each service is autonomous. Orders has its own database. Catalog has its own database. When Orders needs product info, it asks via an API or event. Catalog can change its schema, add columns, or scale independently. Orders doesn't break because Orders doesn't depend on Catalog's implementation—only on its contract.

This is the trade-off: you lose the ability to do strong consistency across services, but you gain independence. In practice, eventual consistency and async communication handle the vast majority of business workflows.

Teams can own boundaries end to end

The deeper benefit: when services map to business capabilities, teams can own them end to end. One team owns Orders. They own the API, the database schema, the business logic, the deployment pipeline. They understand the domain deeply because they live in it every day.

This is the opposite of the layer-based structure, where a frontend team depends on a backend team, which depends on a data team, which depends on infrastructure. Every change requires meetings and coordination. Code reviews bounce between teams because nobody owns the full picture.

With business-capability boundaries, a small team (three to five people) can own Orders completely. They know what state an order can be in. They know what events matter (OrderCreated, OrderShipped, OrderRefunded). They can design the schema, write the API, implement the state machine, and own the SLAs. If Orders needs 99.5% uptime, that team owns making it happen.

This is why Amazon uses "two-pizza teams"—teams small enough to be fed with two pizzas, and each team owns a service. It's not just management theater. It's a direct consequence of letting teams own business boundaries.

I've seen this work remarkably well. A team that owns a single capability becomes expert in that domain. They innovate faster. They deploy more often. They fix bugs quicker because they don't have to wait for another team to get involved.

Chatty services mean the boundary is wrong

Here's the canary in the coal mine: if your services are constantly calling each other, your boundaries are wrong.

For example:

  • Orders calls Billing for every price calculation.
  • Billing calls Users for every customer lookup.
  • Users calls Audit for every state change.
  • Audit calls Logging for every event.

That's a call graph that looks like spaghetti. When you deploy Orders, you have to worry about Billing being available. When Billing is slow, Orders times out. You've built a distributed monolith with all the downsides of distribution and none of the benefits.

The fix is usually to move responsibilities around. If Orders is constantly calling Billing for pricing, maybe pricing logic should live in Orders. If Billing is constantly calling Users, maybe Billing should cache or store the customer data it needs. The goal is for each service to be as self-sufficient as possible.

One pattern that helps: use events. When Orders creates an order, it emits an OrderCreated event. Billing listens to that event and creates an invoice. Fulfillment listens and plans the shipment. Now there's no chatty back-and-forth. Services communicate asynchronously through events. They're decoupled in time and space.

typescript

// Orders emits an event. Other services listen.
class OrderService {
  async createOrder(customerId: string, items: OrderItem[]) {
    const order = new Order(customerId, items);
    await this.ordersDb.save(order);
    
    // Fire and forget. Orders doesn't wait for Billing or Fulfillment.
    await this.eventBus.emit('order.created', {
      orderId: order.id,
      customerId: order.customerId,
      items: order.items,
      total: order.total,
      timestamp: new Date(),
    });
    
    return order;
  }
}

// Billing listens and acts independently
class BillingEventHandler {
  async onOrderCreated(event: OrderCreatedEvent) {
    const invoice = new Invoice(
      event.orderId,
      event.customerId,
      event.total
    );
    await this.billingDb.save(invoice);
  }
}

This pattern makes services truly independent. Orders doesn't know or care that Billing exists. Billing can be down for maintenance, and Orders still works. Billing can be rewritten in a different language, and Orders doesn't care. That's the payoff of proper boundaries.

Common mistakes

  • Creating microservices without understanding the business. If you don't know what a customer, an order, or a subscription really means to your company, you'll split services wrong. Spend time with product and domain experts first. Understand the workflows, the rules, the terminology.
  • Making services too small. A service per database table or per REST endpoint is not a boundary—it's noise. Services should own a capability that takes a team a few months to fully understand and change. If you can rewrite it in a day, it's too small.
  • Sharing databases between services. I've seen teams think they're being practical by having two services read the same database. That's not distribution; that's a monolith with extra steps. You'll face schema coupling, data consistency nightmares, and migrations that affect multiple teams. Own the data or don't call it a boundary.
  • Building synchronous dependencies. If OrderService always waits for BillingService to respond before returning to the client, you've tightened the coupling, not loosened it. Use async messaging or accept eventual consistency.
  • Ignoring domain language. Your services should use terms the business uses. "Orders," "Billing," "Fulfillment." Not "CoreService," "DataLayer," or "ProcessService." Language matters because it connects the code to the business reality.

Key takeaways

  • Split by business capability (Orders, Billing, Catalog) not by technical layer (Database, Logic, Presentation).
  • Each service owns its own data. No shared databases. No direct table access from other services.
  • When a service owns a boundary completely, a small team can own it end to end—API, logic, persistence, deployment.
  • Chatty services are a sign your boundaries are wrong. Use events and async communication to decouple them.
  • Take time to understand the business domain before you draw service boundaries. Bad boundaries built early are expensive to fix later.
  • A service should be big enough that a team needs a few months to fully understand it, and small enough that a team can change it without coordinating with five other teams.
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