A service contract that bundles many unrelated operations couples every consumer to changes that touch any of them. A controller that only needs getOrder() should not depend on a service interface that also exposes invoice generation, shipping updates, and discount logic; splitting it into focused interfaces gives each consumer only what it needs.
Not every broad interface is a violation, though; the question is whether the breadth forces unnecessary coupling.
class OrderQueryService {getOrder(orderId) {} // returns an order}class OrderWorkflowService {createOrder(customerId, items) {} // returns an ordercancelOrder(orderId) {} // returns a confirmation}class BillingService {applyDiscount(orderId, discountCode) {} // returns the updated ordergenerateInvoice(orderId) {} // returns an invoice}
The Repository pattern abstracts data access behind an interface that describes what operations are available without revealing how they are implemented. The business logic layer calls methods on the interface (findById, save, delete) and gets domain data back, never seeing SQL, ORM internals, or connection handling.
When the storage technology changes, only the concrete implementation behind the interface changes; everything that depends on the repository stays the same.
class SqlOrderRepository {findById(orderId) {// runs a SQL query to find the order}save(orderData) {// runs a SQL insert or update}delete(orderId) {// runs a SQL delete}}
In a layered application, Singleton often appears around shared infrastructure resources: a database connection, a connection pool, a configuration object, a logger. Repositories receive one shared instance through dependency injection, so every repository works against the same connection rather than constructing its own.
Modern codebases often express this through composition-root wiring rather than a static getInstance() accessor, though the goal remains the same: one resource, shared from a single source of truth.
// created once at the composition rootconst connection = new DatabaseConnection();// shared across repositories via dependency injectionconst orderRepo = new SqlOrderRepository(connection);const customerRepo = new SqlCustomerRepository(connection);
In a layered application, Adapter often appears at the data access boundary, where a third-party data source (a document store, a key-value cache, a cloud storage SDK) has a different API shape than the repository interface the business logic layer depends on.
The adapter implements the repository contract and translates each call into the third-party API internally, so the service code keeps depending on the same contract regardless of which source is wired in.
The combination of Repository, Adapter, and dependency injection means swapping data sources can become a configuration change at the wiring point rather than a code change in the business logic layer.
class ThirdPartyDocStore { // adapteegetDocument(collection, id) {} // returns a raw documentupsertDocument(collection, data) {} // returns a raw documentremoveDocument(collection, id) {} // returns a status code}class DocStoreOrderAdapter { // adapterconstructor(docStore) {this.docStore = docStore; // the third-party client}findById(orderId) {// calls this.docStore.getDocument("orders", orderId)}save(orderData) {// calls this.docStore.upsertDocument("orders", orderData)}delete(orderId) {// calls this.docStore.removeDocument("orders", orderId)}}
An AI agent can identify internal design problems within layers, evaluate whether a chosen pattern actually fits, and validate that an applied pattern solves the original problem rather than just adding indirection. Sharing a controller alongside its service and presenter, or a repository alongside the third-party API it adapts, gives the agent enough context to reason about whether a refactor genuinely reduced coupling or just relocated it. The agent’s evaluation works best as a verification step on top of careful reading of the code.
Fat controllers violate the Single Responsibility Principle by combining request handling with orchestration, business validation, response formatting, and partial business rules.
Each of those concerns has a different reason to change, so bundling them into one function makes the controller fragile: a permission policy update, a display format tweak, and a request shape change all alter the same code. The signal is whether logic with a different reason to change has accumulated alongside the request handling, regardless of function size.
function getOrder(req, res) {const order = orderService.findById(req.params.id);// business rule has crept in: only admins can see canceled ordersif (order.status === "canceled" && req.user.isAdmin === false) {return res.status(403).send("access denied");}// view formatting has crept inconst formatted = formatForDisplay(order);res.send(formatted);}
A thin controller stays focused on request and response handling and delegates everything else: business validation moves to the service layer, view formatting moves to the presenter, and business rule decisions stay out of the controller entirely. The controller only changes when its coordination concerns change, not when business rules or display formats do. The right size is a controller whose responsibilities all share the same reason to change.
function getOrder(req, res) {const order = orderService.getOrder(req.params.id, req.user);const formatted = orderPresenter.present(order);res.send(formatted);}
Presenters insulate view templates from backend changes by transforming service data into view-ready formats. When backend refactoring changes the shape of a service’s return value (a renamed field, a moved nested object, a different unit of measure), the presenter absorbs the difference and the template keeps rendering the same final output. Without that buffer, every internal data shape change ripples into the frontend.
// service shape before refactor: order.total, order.createdAt// service shape after refactor: order.totalInCents, order.timestamps.created// the presenter absorbs the difference; the template renders the same outputfunction present(order) {return {totalDisplay: formatCurrency(order.totalInCents),placedOnDisplay: formatDate(order.timestamps.created),};}
The Service Layer pattern organizes the business logic layer into services that each expose an explicit contract: what operations the service offers, what input each one expects, and the shape of the output. Consumers depend on the contract rather than on how the service works internally, which keeps changes contained and gives new logic a clear home. Each service ends up with a focused scope and a defined boundary that other layers can reason about.
class OrderService {createOrder(customerId, items) {} // returns an ordergetOrder(orderId) {} // returns an ordercancelOrder(orderId) {} // returns a confirmation}class InventoryService {checkAvailability(items) {} // returns an availability resultreserveItems(orderId, items) {} // returns a reservation}
A long conditional chain handling multiple variations of the same business rule is a signal that the Strategy pattern might fit. Each new case forces editing the existing function, which is exactly what the Open/Closed Principle warns against. Not every conditional is a violation. A two-case branch unlikely to grow is fine, but a chain that keeps expanding is the moment to consider encapsulating the variations behind a common interface.
function calculateDiscount(tier, orderTotal) {if (tier === "gold") {return orderTotal * 0.20;}if (tier === "silver") {return orderTotal * 0.10;}if (tier === "bronze") {return orderTotal * 0.05;}return 0;}// adding a new tier means editing this function
In a layered application, Strategy often fits naturally in the business logic layer, since business rules tend to change more often than request handling or data access and to come in many variants. A service that holds a strategy by interface can swap pricing tiers, eligibility checks, or processing rules without touching the surrounding workflow. The result is business variation concentrated in strategy classes, which is the Open/Closed Principle holding inside the business logic layer.
// Rigid conditional chains can signal a need for Strategyfunction calculateDiscount(tier, orderTotal) {if (tier === 'gold') return orderTotal * 0.20;if (tier === 'silver') return orderTotal * 0.10;if (tier === 'bronze') return orderTotal * 0.05;}// Strategy replaces brittle branching with interchangeable behaviorclass GoldDiscountStrategy {calculate(orderTotal) {return orderTotal * 0.20;}}class SilverDiscountStrategy {calculate(orderTotal) {return orderTotal * 0.10;}}
Factory Method often fits in the service layer when paired with Strategy. The processor base class declares a workflow that calls an abstract creation method; subclasses override it to return the appropriate strategy, and the service is wired with whichever processor variant fits the context. Adding a new behavior typically means a new strategy class plus a new processor subclass, which preserves the Open/Closed Principle end to end.
For simpler cases where a subclass would do nothing beyond returning a strategy, teams often skip the processor layer and use a map of strategy instances instead.
class OrderProcessor {processOrder(orderTotal) {const strategy = this.createDiscountStrategy();return strategy.calculate(orderTotal);}createDiscountStrategy() {throw new Error("subclasses must implement");}}class GoldOrderProcessor extends OrderProcessor {createDiscountStrategy() {return new GoldDiscount();}}
Each SOLID principle has a specific role in repository design. SRP keeps each repository focused on a single entity type rather than spanning unrelated tables. OCP keeps the interface stable while new query methods or implementations are added. LSP ensures that any concrete repository can stand in for the interface without breaking callers. ISP keeps repository contracts narrow enough that consumers depend only on the operations they use. DIP keeps the business logic layer depending on the abstract interface rather than the concrete database technology.