MSC (Model-Service-Controller) is a useful pattern for keeping business logic separate from HTTP handling and data access. The model owns the schema. The controller owns request/response. The service owns everything in between. What the pattern doesn't tell you is how to organize the service layer itself. Should you create one service per model? Group services by domain or goal? Use a mix? That question has a real answer, and it depends on what your application actually does.

Understanding MSC in the Modern Software Ecosystem

In MSC architecture:

  • Models encapsulate data schemas and business objects.
  • Services contain business or domain logic that operates on models.
  • Controllers handle incoming requests, call services, and return responses.

The service layer is where the real design decisions live. A well-structured service layer keeps code navigable as the application grows, prevents logic from leaking into controllers, and makes individual services testable in isolation. TypeScript adds strong typing, interfaces, and generics that make these boundaries easier to enforce and harder to accidentally violate.

The Challenge of Structuring Services

There are three main approaches, each with a different tradeoff.

One service per model is easy to visualize. UserService handles users, ProductService handles products, OrderService handles orders. For small applications or simple CRUD operations, this is the right starting point. The risk is that cross-cutting logic, like sending a confirmation email after an order is placed, ends up duplicated across services or awkwardly shoe-horned into whichever service handles the triggering action.

Services organized by goal groups logic around domain operations rather than data entities. Instead of separate ProductService and OrderService, you might have a CheckoutService that handles the full purchase flow. This aligns well with how the business thinks about its operations and keeps related steps close together. The risk is that a domain service can absorb too many responsibilities if its scope isn't clearly defined, making it just as tangled as the controller it was supposed to replace.

A hybrid approach uses model-focused services for straightforward CRUD and domain-focused services for operations that span multiple models. A UserService handles creating and retrieving users. An AnalyticsService aggregates data from multiple model services to produce reports. This is the approach that scales best, but it requires ongoing judgment about where to draw the lines.

One Service per Model: Pros and Cons

The main advantages are simplicity and discoverability. Every developer immediately knows where to look for user-related logic. New team members can orient themselves quickly. For smaller applications or individual microservices with narrow responsibilities, this approach has no significant downsides.

The problems appear when operations span multiple models. Email notifications, tax calculations, audit logging, and other cross-cutting concerns either end up duplicated in each service that needs them or become methods on whatever service felt most convenient at the time. Neither outcome is clean.

A model-focused service in TypeScript:

// models/User.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

// services/UserService.ts
import { User } from '../models/User';

export class UserService {
  private users: User[] = [];

  public createUser(userData: Omit<User, 'id'>): User {
    const newUser: User = {
      id: Math.random().toString(36).substring(2),
      ...userData
    };
    this.users.push(newUser);
    return newUser;
  }

  public getUserById(userId: string): User | undefined {
    return this.users.find(user => user.id === userId);
  }

  // Additional CRUD logic, if necessary
}

UserService has one job: manage user data. That's all it should do.

Goal-Focused Services: Going Domain-Driven

Goal-focused services make sense when the meaningful operations in your system naturally involve multiple models working together. An e-commerce checkout involves a user, an order, a payment, and an email. Splitting that logic across four separate model services means the checkout flow lives in four places, which makes it harder to understand and harder to change.

// domain/EcommerceService.ts
import { User } from '../models/User';
import { Order } from '../models/Order';
import { EmailService } from './EmailService';
import { PaymentGateway } from './PaymentGateway';

export class EcommerceService {
  constructor(
    private emailService: EmailService,
    private paymentGateway: PaymentGateway
  ) {}

  public async processOrder(user: User, order: Order): Promise<void> {
    // Execute payment
    if (await this.paymentGateway.charge(user, order.total)) {
      // Save order to database or in-memory store
      // ...some logic here

      // Send confirmation email
      await this.emailService.sendEmail(user.email, 'Order Confirmed', 'Your order has been processed successfully!');
    }
  }
}

EcommerceService orchestrates the full checkout sequence. EmailService and PaymentGateway are injected as dependencies, keeping their logic separate and testable. The domain service stays focused on the orchestration, not the implementation details of payment or email.

The risk to watch for: a service that starts handling orders, then absorbs product catalog logic, then inventory management, then returns. At that point it's no longer a focused domain service; it's a catch-all that needs to be split.

The Hybrid Route: Picking the Best of Both Worlds

The hybrid approach uses model services for straightforward CRUD and domain services for cross-cutting operations. Model services handle individual entities cleanly. Domain services call into model services to get data, then perform higher-level operations on it.

The AnalyticsService below is a good example: it doesn't own any data directly, but it pulls from multiple model services to compute aggregate metrics.

// services/AnalyticsService.ts
import { UserService } from './UserService';
import { OrderService } from './OrderService';

export class AnalyticsService {
  constructor(
    private userService: UserService,
    private orderService: OrderService
  ) {}

  public async getAverageSpendingPerUser(): Promise<number> {
    const users = this.userService.getAllUsers();
    let totalSpending = 0;
    for (const user of users) {
      const orders = this.orderService.getOrdersByUserId(user.id);
      const userSpending = orders
        .map(order => order.total)
        .reduce((acc, cur) => acc + cur, 0);
      totalSpending += userSpending;
    }
    return totalSpending / users.length;
  }
}

AnalyticsService has clear boundaries: it reads data through existing services and computes a result. It doesn't create or update anything. That clarity makes it straightforward to test and easy to reason about.

Tips for Evolving Your MSC Architecture

Start with one service per model and split only when a service is doing too many things or cross-cutting logic is being duplicated. Premature abstraction into domain services creates complexity before you know what the domain actually needs.

TypeScript's type system helps enforce service boundaries. Define interfaces for what each service accepts and returns. Use abstract classes or interfaces to make injection points explicit. Generics can reduce boilerplate for services that do similar things across different model types.

In serverless architectures, each Lambda function often maps directly to a service operation. The same service boundary discipline applies: each function should have one clear job. Message brokers (AWS SNS/SQS, RabbitMQ, Kafka) handle communication between services without tight coupling.

When a domain service gets large, break it up incrementally. Extract one responsibility at a time, verify nothing breaks, then continue. Large-scale reorganization done all at once is harder to review and more likely to introduce bugs.

Why Proper Service Organization Matters

As an application scales, the service layer is where most of the complexity accumulates. Poor service organization leads to duplicated logic, unclear ownership, and services that are hard to test because they depend on too many things. Good organization means each service has a clear job, its dependencies are explicit, and tests for it don't require the entire application to be running.

For teams, clear service boundaries reduce coordination overhead. Front-end developers, backend engineers, and DevOps can work in different parts of the codebase without constantly stepping on each other. Onboarding is faster because the structure tells you where things belong.

The pattern also pays off during debugging. When something goes wrong in production, a well-organized service layer makes it much easier to trace which service is responsible for the failure and what data it was working with.

Conclusion

The right service organization depends on what your application does. One service per model is the right starting point for most applications. Add domain-focused services when operations consistently span multiple models and the code for those operations keeps getting scattered. The hybrid approach is the practical default for applications of meaningful size.

Whatever structure you choose, keep service boundaries explicit, inject dependencies rather than importing them directly, and don't wait until a service is a mess to split it. The earlier you notice a service accumulating responsibilities it shouldn't have, the easier the refactor.