← Root & Branch

10 Backend Design Patterns Explained Through One Payment System

Repository, factory, singleton, strategy, observer, builder, adapter, decorator, CQRS and circuit breaker, each shown in the checkout of one online store: the problem it solves, how it works, and when to skip it.


Most backend code starts simple: a route, a query, a response. The trouble comes later, when ten people edit the same files, a second payment provider shows up, and a partner's API goes down on a Friday night. Design patterns are named, well-tested answers to problems like these. They are not rules to memorise. Each one exists because a particular kind of code kept hurting people, so the useful way to learn them is problem first: what goes wrong without the pattern, what the pattern changes, and when it is not worth the extra code.

To keep things concrete, this post follows one system all the way through: the checkout of an online store. A customer presses Pay on a ₹499 order. Behind that one click, the backend checks who they are, works out the price, picks a payment provider, talks to that provider's API, saves the order, tells other parts of the business about it, and later shows it in the customer's order history. Each of the ten patterns below handles one piece of that journey. The map shows where each one sits.

One checkout touches all ten patterns: middleware (decorator), checkout building the request (builder), discount rules (strategy), payment factory, Razorpay adapter, Razorpay API behind a circuit breaker, order repository, OrderPlaced event (observer), order history read view (CQRS), and one shared config (singleton).Middlewareauth · logging8Checkoutbuilds the request6Discount rulespremium · student4Payment factorypicks Razorpay2Razorpay adaptertranslates7Razorpay APIbehind a breaker10Order repositorysaves the order1OrderPlaced eventemail · stock5Order historyfast read view93Config: one shared copyone payment, ten patterns

1. Repository: keep the database behind one door

The quickest way to load an order is to write the SQL right inside the payment service. That works until the same class also validates input, applies refund rules and formats API responses. Now one file knows about table names and business rules at the same time. You cannot test a refund rule without a real database, and renaming a column means hunting through every class that ever touched the orders table.

The repository pattern puts all data access behind one object with plain methods such as find(orderId) and save(payment). The payment service asks for an order by what it means and never sees SQL. The repository is the only place that knows whether orders live in PostgreSQL, MySQL or MongoDB. In tests you hand the service a fake repository that keeps orders in memory, so refund rules can be checked in milliseconds with no database running.

The payment service asks the order repository to find order 42; only the repository writes SQL against Postgres. In tests, a fake repository that keeps orders in memory takes its place.asks by meaningknows the databasePaymentServicecharge rulesrefund rulesno SQL herefind(42)orderOrderRepositoryfind · saveSELECT … FROMorders WHERE id = 42SQLPostgresFakeRepositoryorders kept in memoryin testsThe service asks for orders; only the repository speaks SQL

Use it when business logic and storage code are starting to mix, or when you want to unit test rules without a database. Spring Data in Spring Boot, Entity Framework in ASP.NET Core and TypeORM in NestJS are all built around this idea. Skip the extra layer for a small script, or for a service that is little more than a thin wrapper over one table.

2. Factory: one place decides which object to create

The store starts with Stripe, so the checkout code says new StripePayment() wherever it needs to charge someone. Then the business adds Razorpay for India and PayPal for everyone else. Now every place that created a Stripe object needs an if statement, and the next provider means editing all of those places again, with a good chance of missing one.

A factory is the single place that knows how to create the right payment provider. Checkout says 'give me a provider for this order' and gets back an object with a charge() method. The factory reads the order's country, or a config setting, and decides: Stripe for the US, Razorpay for India, PayPal otherwise. The value is not the factory class itself. It is that creating objects now happens in one spot, so adding a provider means one new class and one new line in the factory.

Checkout asks the payment factory for a provider. The factory reads the config (country: IN) and its rules (US to Stripe, IN to Razorpay, else PayPal) and hands back Razorpay.Checkoutpay ₹499provider?country: INPaymentFactoryUS → StripeIN → Razorpayelse → PayPalStripeRazorpayPayPalNew provider? One new class, one new line in the factory

Use it when there are several implementations of the same job and the right one depends on configuration, region or input. Skip it while there is only one implementation and no sign of a second; a factory that always returns the same thing is just one more file to read.

3. Singleton: exactly one shared instance

Some things should exist only once in a running application: the payment configuration with its API keys and timeouts, a database connection pool, a logger. If every request built its own connection pool, the database would run out of connections within minutes. If two parts of the app each loaded their own copy of the config, they could quietly disagree about which API key or timeout to use.

A singleton guarantees one instance for the life of the application, and everyone who asks for it gets that same instance. Checkout, refunds and the webhook handler all read the one PaymentConfig and share the one connection pool. Today you rarely write the classic getInstance() method yourself. Dependency injection in Spring, ASP.NET Core and NestJS gives a class a singleton lifetime with one line of setup, and still lets tests swap in a different instance.

Checkout, Refunds and Webhooks all point at the same single PaymentConfig instance holding API keys, timeouts and the connection pool. A second copy is never created.CheckoutRefundsWebhooksthe one instancePaymentConfigAPI keystimeouts · poola second copynever createdEveryone shares one instance, so settings never drift

Use it for shared, expensive resources, and be careful with it everywhere else. A singleton that holds changing state is a global variable with a nicer name: any code can change it, bugs show up far from their cause, and tests start depending on each other. Share an instance because the resource needs sharing, not because it is convenient to reach from anywhere.

4. Strategy: swap the rule, keep the code that uses it

Pricing at checkout starts with one rule. Then premium members get 10% off, students get 15% off, and the festival sale takes ₹200 off. The tempting fix is one calculateTotal() method with a growing chain of if and else if branches. A year later it holds every promotion the company ever ran, nobody wants to touch it, and a change to the student discount can break the festival sale.

The strategy pattern moves each rule into its own small class, and every one of them follows the same interface: say, a DiscountStrategy with an apply(cart) method. Checkout picks the right strategy for this customer and calls apply(). It does not care how the discount is worked out. When marketing invents a Black Friday deal, you add one new strategy and leave the existing ones alone.

Checkout calls strategy.apply(cart) and picks one discount strategy per customer: premium 10% off, student 15% off or festival ₹200 off. A new Black Friday strategy can be added without touching the others.each one is a DiscountStrategyCheckouttotal =strategy.apply(cart)picks one per customerPremium: 10% offStudent: 15% offFestival: ₹200 off+ Black Friday (new)Each rule lives in its own class; a new rule touches nothing else

Use it whenever the same job can be done in several interchangeable ways: discounts, tax rules, shipping costs, login methods, retry policies. It is easy to confuse with the factory, and the two often work together: the factory decides which object to create, and the strategy is the object that does the varying work. Skip it for two simple branches that never change, where a plain if statement is easier to read.

5. Observer: announce what happened, let others react

When an order is paid, a lot has to happen next: reserve stock, send a confirmation email, add loyalty points, record the sale in analytics, tell the warehouse to pack it. If the order service calls each of these directly, it ends up depending on five other teams' code. A slow email server now slows down checkout, and every new follow-up step means editing the order service again.

With the observer pattern, the order service only announces an event, OrderPlaced, along with the order details. Anything that cares subscribes to that event and reacts on its own: inventory reserves stock, the email service sends the receipt, loyalty adds points. The order service does not know who is listening. When the business wants a thank-you coupon after every purchase, a new listener subscribes and the order service does not change at all.

The order service publishes one OrderPlaced event. Inventory, email, loyalty, analytics and warehouse each listen and react on their own, and a new coupon listener can join later without changing the order service.OrderServicepublishesevent: OrderPlaced #42Inventoryreserve stockEmailsend receiptLoyaltyadd pointsAnalyticsrecord saleWarehousepack the box+ Couponadded laterThe order service never knows who is listening

Inside one application this can be a simple in-memory event emitter. Across services it becomes event-driven architecture, with a message broker such as Kafka, RabbitMQ or Azure Service Bus carrying the events. Use it when one action triggers many independent follow-ups. The cost is that the flow is harder to trace, because no single function shows everything that happens after a payment, so clear event names and good logging matter.

6. Builder: assemble a complex object step by step

A request to the payment provider has a few required fields and many optional ones: amount, currency, customer, billing address, saved card, number of retries, description, return URL. Passed through a constructor, it looks like new PaymentRequest(499, "INR", null, null, true, 3, null, false). Nobody can tell what each true or null means, and swapping two arguments of the same type compiles fine and sends the wrong request.

A builder lets you set each field by name, one step at a time, and then call build(). The code reads like a sentence: amount 499, currency INR, customer c_17, retries 3, build. Fields you do not need are simply left out. And build() is a natural place to check the whole object once, for example refusing a request with no amount, before anything reaches the provider.

Before: new PaymentRequest with eight positional arguments, and nobody knows which one is retries. After: a builder names each field (amount, currency, customer, retries) and build() checks the finished request once.Beforenew PaymentRequest(499, "INR", null, null, true, 3, null, false)which one is the retry count?After.amount(499).currency("INR").customer("c_17").retries(3).build()PaymentRequestamount: 499currency: INRcustomer: c_17retries: 3✓ checked onceName every field, then build() checks the whole request

Use it for objects with many optional fields or fiddly setup: API requests, HTTP clients, configuration. Lombok's @Builder in Java and the request builders in the AWS SDK follow this shape, and in TypeScript an options object with named fields solves much of the same problem. For an object with two or three required fields, a normal constructor is clearer.

7. Adapter: translate between your code and someone else's

Every outside system speaks its own dialect. Stripe wants the amount in the smallest currency unit, 49,900 paise for ₹499, sent as JSON. An older bank gateway wants XML with an AMT field in rupees. One API calls the customer customer_id, another calls it id. If these details leak into checkout, every part of the code has to know every provider's quirks, and when a provider changes its API you are fixing code all over the place.

An adapter sits between your app and the outside system and translates in both directions. Your code makes one call in your own terms, charge(499, "INR"). The StripeAdapter turns that into Stripe's request; the BankAdapter turns it into the bank's XML. Their responses come back translated into your own Payment result. It works like a travel plug adapter: your charger and the hotel's wall socket never change, and the small piece in between makes them fit.

Checkout makes one call, charge(499, INR). The Stripe adapter turns it into JSON with 49,900 paise for the Stripe API; the bank adapter turns it into XML with AMT 499.00 for an old bank gateway.our languagetheir languageCheckoutcharge(499,"INR")adapters translateStripeAdapter₹ → paise, JSONStripe API{ amount: 49900 }BankAdapter₹ → XMLOld bank<AMT>499.00</AMT>The app makes one call; each adapter speaks one provider's dialect

Use it for every third-party API, legacy system or library whose interface you do not control. It pairs naturally with the factory: the factory picks which adapter to use, and each adapter hides one provider's details. When a provider changes its API, ideally only its adapter changes.

8. Decorator: add behaviour by wrapping, not editing

The charge() method starts small. Then the team needs a log line for every charge, then timing metrics, then a permission check. If each of these goes inside charge(), the code that moves money is soon buried under code that has nothing to do with moving money, and every change to logging risks breaking payments.

A decorator wraps an object, offers exactly the same methods, and adds one behaviour before or after passing the call inward. An AuthCheck wrapper checks permission, then calls the next layer. A Logging wrapper writes a log line and calls the next one. A Metrics wrapper times the call. Because each wrapper looks just like the thing it wraps, you can stack them in any combination, and the core charge() stays small and focused.

The core charge() method sits inside three wrappers: metrics, then logging, then an auth check. A call passes through each layer on its way in, and the core never changes.Auth checkLoggingMetricscharge()calleach layer:· same methods· adds one job· calls inwardcore never changesStack wrappers around the service; the core stays small

You already use this idea if you have written middleware in Express, NestJS or ASP.NET Core: each piece wraps the next handler to add authentication, logging or caching. Use it for concerns that cut across many classes. Keep the stack short and its order deliberate, because with too many layers, finding where a request was changed becomes a puzzle of its own.

9. CQRS: separate the path that writes from the path that reads

Writes and reads in a store look nothing alike. Paying for an order is rare and strict: check stock, validate the payment, update several tables in one transaction. Viewing order history is frequent and simple, but it wants data joined across orders, payments and shipments, and it has to be fast. One model and one database tuned for both ends up mediocre at each: heavy joins slow the reads, and indexes added for the reads slow the writes.

CQRS stands for Command Query Responsibility Segregation. Commands change data (PlaceOrder, RefundPayment) and go through a write model that holds all the business rules. Queries only read data (GetOrderHistory) and go through a separate read model shaped for the screen that shows it. In a large system the two sides can use different databases: the write side keeps normalised tables, and events keep a ready-made read view up to date for fast lookups.

CQRS: commands such as PlaceOrder and Refund go through a write model with all the business rules into a normalised write database. Queries such as GetOrderHistory go through a read model into a read database of ready-made views, kept in sync by events.Commands (write)PlaceOrder · RefundWrite modelall business ruleswrite DBnormalised tablesQueries (read)GetOrderHistoryRead modelshaped for the screenread DBready-made viewsevents keep it in syncWrites and reads take separate paths, each tuned for its job

Use it when reads and writes have very different loads or shapes, such as a feed read millions of times a day and written thousands. Know the cost: the read side usually trails the write side by a moment (eventual consistency), so a customer may refresh and not yet see the order they just placed. For an ordinary create-read-update-delete app that cost buys nothing, so leave CQRS out.

10. Circuit breaker: stop calling a service that is down

Suppose the payment gateway goes down. Without protection, every checkout still calls it and waits for a timeout, say 30 seconds. Each waiting request holds a thread and a connection. This is Little's Law at work: at 50 checkouts a second, 30-second waits mean 1,500 requests stuck in flight. Soon your own service runs out of threads and stops serving pages that have nothing to do with payments. A failure outside your system has become an outage inside it.

A circuit breaker sits in front of the call and counts failures. While things are healthy it is closed, and calls flow normally. After, say, five failures in a row it opens: for the next 30 seconds every call fails immediately with a fallback, such as 'payments are busy, please try again in a minute', instead of waiting on a dead service. When the time is up it goes half-open and lets one test call through. If that call works, the breaker closes again; if it fails, the breaker opens for another round.

A circuit breaker between checkout and a failing payment gateway has three states. Closed: calls flow. After 5 failures in a row it opens and fails fast. After 30 seconds it goes half-open and sends one test call: if it works the breaker closes, if it fails it opens again.CheckoutbreakerPayment gatewaydown ✗CLOSEDcalls flowOPENfail fastHALF-OPENone test call5 failures in a rowafter 30 stest workstest failsFail fast while the gateway is down; test it before trusting it again

Use it on calls to any dependency that can fail over the network, which is all of them. Resilience4j for Java and Polly for .NET provide it as a library, and service meshes such as Istio can apply it without touching your code. Pair it with timeouts and a small number of retries, and decide up front what the customer should see while the circuit is open.

How the ten work together

Back to the ₹499 order. The request passes through middleware that wraps the handler with auth and logging (decorator). Checkout asks a strategy for the discounted price and assembles a PaymentRequest with a builder. The factory picks Razorpay because the customer is in India, and the Razorpay adapter translates the request into Razorpay's format, behind a circuit breaker in case the gateway is struggling. Every step reads the same configuration (singleton). The paid order is saved through a repository, an OrderPlaced event wakes up stock, email and loyalty (observer), and the order history page reads from its own fast view (CQRS).

None of these patterns is a goal on its own. Each adds a little indirection, and that cost is worth paying only when you can name the problem it solves: database code mixed into business rules, a pile of if statements that keeps growing, an outside API you cannot control, a dependency that keeps falling over. Learn the problem first and the pattern second. Then, when a codebase starts to hurt in one of these ways, you will recognise the shape and know the fix.