Skip to content

← Writing

engineering

10 Backend Design Patterns Worth Knowing Before 2027

· Jerwin Arnado · 17 min read ·

When you start backend development, everything looks simple. Pick a language, pick a framework, connect a database, build a few endpoints, and it works. Then the application grows. More features land, more developers join the same codebase, and the business keeps changing its mind. Code that looked fine on day one becomes hard to read and risky to touch.

Design patterns exist for that moment. They aren’t rules to memorise for an interview. They’re proven shapes for problems that keep coming back. So instead of definitions, here are ten of them the way I actually met them: one problem at a time. Examples are in PHP and Laravel, because that’s where I live, but the ideas carry over to any stack.

1. Repository: keep the database out of your business logic

The problem. Your controller builds Eloquent queries inline. So does the job, the console command, and three services. Now you want to unit test the checkout logic without a database, or move a table to a different store, and every one of those call sites has to change.

The pattern. Put data access behind one class that speaks your domain’s language.

interface OrderRepository
{
    public function find(int $id): ?Order;
    public function pendingOlderThan(CarbonInterface $cutoff): Collection;
    public function save(Order $order): void;
}

final class EloquentOrderRepository implements OrderRepository
{
    public function pendingOlderThan(CarbonInterface $cutoff): Collection
    {
        return Order::where('status', 'pending')
            ->where('created_at', '<', $cutoff)
            ->get();
    }
    // ...
}

Business code asks for pendingOlderThan($cutoff), never where('status', ...). In tests, you bind a fake. If the storage changes, one class changes.

The honest caveat. In Laravel, Eloquent already is a data-access layer, and wrapping every model in a repository “just in case” is a classic way to double your file count for nothing. Reach for it when the same query logic is repeated in many places, or when the data really might live somewhere other than the main database. Swapping MySQL for PostgreSQL is not a good reason by itself. The query builder already handles that.

2. Factory: one place to create the right thing

The problem. You support Stripe, PayMongo and PayPal. Every place that charges a customer has the same if ($provider === 'stripe') { new StripeGateway(...) } elseif ... block, and adding a fourth provider means hunting down all of them.

The pattern. Creation logic lives in exactly one place.

final class PaymentGatewayFactory
{
    public function make(string $provider): PaymentGateway
    {
        return match ($provider) {
            'stripe'   => new StripeGateway(config('services.stripe.secret')),
            'paymongo' => new PayMongoGateway(config('services.paymongo.secret')),
            'paypal'   => new PayPalGateway(config('services.paypal.client_id')),
            default    => throw new InvalidArgumentException("Unknown provider [$provider]"),
        };
    }
}

Callers just ask for $factory->make($tenant->payment_provider) and get something that implements PaymentGateway. They never know which concrete class they got.

Laravel uses this idea everywhere. Cache::store('redis'), Storage::disk('s3') and Mail::mailer('ses') are all factories (Laravel calls them managers). If you only ever have one product, you don’t need a factory. Just call new.

3. Singleton: one shared instance, used carefully

The problem. Configuration, a logger, an expensive SDK client. You want one instance for the whole request, not a new connection every time someone needs it.

The pattern. Make sure only one instance exists and everyone shares it. The textbook version uses a private constructor and a static getInstance(). In a framework with a container, you almost never write that yourself:

// AppServiceProvider::register()
$this->app->singleton(GeoIpClient::class, fn () => new GeoIpClient(
    database: storage_path('geoip/GeoLite2-City.mmdb'),
));

Now every class that type-hints GeoIpClient gets the same instance.

Why it’s so easy to overuse. A singleton is global state with a nicer name. It hides dependencies (nothing in a constructor says “this class secretly reads the global config”), it makes tests leak into each other, and under long-running workers like Octane, FrankenPHP or queue workers, a singleton that holds per-request data will happily serve user A’s data to user B. Use it for things that are truly stateless or truly shared, like clients, config and loggers. Never use it for anything that knows who the current user is.

4. Strategy: replace the growing if-else pile

The problem. Discounts. It starts with “10% off for members”. Then a holiday promo, then buy-two-get-one, then senior citizen and PWD discounts, then a code that only works on Tuesdays. The calculateDiscount() method is now 200 lines of if and nobody wants to touch it.

The pattern. Each rule becomes its own small class behind a shared interface, and you pick one at runtime.

interface DiscountStrategy
{
    public function apply(Cart $cart): Money;
}

final class MemberDiscount implements DiscountStrategy
{
    public function apply(Cart $cart): Money
    {
        return $cart->subtotal()->multiply('0.10');
    }
}

final class SeniorCitizenDiscount implements DiscountStrategy
{
    public function apply(Cart $cart): Money
    {
        return $cart->subtotal()->multiply('0.20');
    }
}

// the checkout doesn't care which one it got
$discount = $strategy->apply($cart);

A new promo is a new class, not another branch in a method everyone is scared of. Each strategy is tiny and testable on its own. Strategy and Factory often show up together: the factory decides which strategy, the strategy decides how.

5. Observer: let the rest of the system react

The problem. When an order is placed you need to reserve inventory, send the confirmation email, award loyalty points and record analytics. If OrderService::place() calls all four directly, it now depends on four other modules, and a slow email server makes checkout slow.

The pattern. The order service announces what happened. Anything that cares subscribes.

// the publisher knows nothing about who's listening
OrderPlaced::dispatch($order);

// each listener lives in its own module
class ReserveInventory  { public function handle(OrderPlaced $e) { /* ... */ } }
class SendConfirmation implements ShouldQueue { public function handle(OrderPlaced $e) { /* ... */ } }
class AwardLoyaltyPoints implements ShouldQueue { public function handle(OrderPlaced $e) { /* ... */ } }

Adding “notify the warehouse on Slack” is a new listener, with zero changes to checkout. Mark listeners ShouldQueue and they stop slowing down the request too.

Scale this idea across services instead of classes and you get message brokers: Kafka, RabbitMQ, AWS SNS, Google Pub/Sub, Azure Service Bus. Same idea, with a network in the middle. The trade-off is the same at both scales: the flow gets harder to follow, because nothing in place() tells you what happens next. I wrote more about that in Sync vs Async.

6. Builder: when a constructor has fifteen parameters

The problem.

new Report($title, $from, $to, null, null, true, false, 'pdf', 'A4', 'landscape', null, true);

What does the sixth true mean? Nobody knows, including the person who wrote it last month.

The pattern. Build the object step by step with named, readable calls, then finish with build(), which is a good place to validate.

$report = ReportBuilder::make('Monthly Sales')
    ->between($from, $to)
    ->includeCharts()
    ->format('pdf')
    ->paper('A4', landscape: true)
    ->build();

You’ve used this a thousand times already: Laravel’s query builder, Http::withToken()-> timeout(5)->retry(3)->get(), and mailables are all builders. PHP 8’s named arguments solve the small version of this problem for free, so new Report(title: ..., landscape: true) is often enough. Reach for a builder when the construction has real steps or rules, like “charts require a date range.”

7. Adapter: one interface for many outside APIs

The problem. Three SMS providers. One wants to/body, another wants msisdn/text, the third returns errors as HTTP 200 with {"status": "FAILED"}. If your code talks to them directly, every provider’s quirks leak into your business logic.

The pattern. Define the interface you want, and write a thin translator per provider.

interface SmsSender
{
    /** @throws SmsFailed */
    public function send(string $phone, string $message): void;
}

final class SemaphoreSmsAdapter implements SmsSender
{
    public function send(string $phone, string $message): void
    {
        $res = Http::post('https://api.semaphore.co/api/v4/messages', [
            'apikey'  => $this->key,
            'number'  => $phone,
            'message' => $message,
        ]);

        // the provider's weirdness stops here
        if ($res->failed() || data_get($res->json(), '0.status') === 'Failed') {
            throw new SmsFailed($res->body());
        }
    }
}

The rest of the app only ever sees SmsSender::send(). Switching providers, or falling back to a second one, never touches business code. This is the pattern I reach for most often in real projects, because every third-party API has some quirk you don’t want to spread around.

8. Decorator: wrap behaviour without changing the original

The problem. You want caching on the product catalog. And logging. And timing metrics. And an authorization check. Stuff all of it into ProductService and the actual business logic disappears under cross-cutting noise.

The pattern. Wrap the service in another class with the same interface, which does its extra thing and delegates the rest.

final class CachedProductCatalog implements ProductCatalog
{
    public function __construct(private ProductCatalog $inner) {}

    public function find(int $id): ?Product
    {
        return Cache::remember("product:$id", 300, fn () => $this->inner->find($id));
    }
}

// wire it once in the container
$this->app->bind(ProductCatalog::class, fn ($app) =>
    new CachedProductCatalog(new EloquentProductCatalog())
);

The original class never knows it’s being cached. Stack another decorator for logging, then one for metrics: each layer does one job.

If this sounds familiar, it should. Middleware is the decorator pattern applied to HTTP requests. auth, throttle and cache.headers each wrap the next handler, do their part, and pass it on. (Caching has its own traps, which I covered in Caching Patterns and Their Failure Modes.)

9. CQRS: separate the writes from the reads

The problem. The same Order model serves two very different jobs. Writes need validation, invariants and transactions. Reads need a dashboard that joins six tables, aggregates by month and loads in under 200ms. One model optimised for both ends up good at neither.

The pattern. Command Query Responsibility Segregation. Commands change state and return little or nothing. Queries return data and change nothing. Once they’re split, each side can be shaped, and scaled, on its own.

// write side: rules, transaction, events
final class PlaceOrder
{
    public function handle(PlaceOrderCommand $cmd): void
    {
        DB::transaction(function () use ($cmd) {
            $order = Order::create([/* validated fields */]);
            OrderPlaced::dispatch($order);
        });
    }
}

// read side: a flat, denormalised table built for the dashboard
final class SalesDashboardQuery
{
    public function monthly(int $year): Collection
    {
        return DB::table('sales_monthly_summary')->where('year', $year)->get();
    }
}

A listener on OrderPlaced keeps sales_monthly_summary up to date. Reads hit a table shaped exactly for them, maybe on a read replica, and never fight the writes for locks.

When it’s worth it. Usually not on day one. The lightweight version, separate classes for commands and queries in the same database, is cheap and clarifying, and I’d recommend it almost anywhere. The heavy version, with separate stores and event-driven projections, buys scale at the cost of eventual consistency: the dashboard can lag the truth by a few seconds, and you need to be OK with that. Only go there when read load or read complexity really demands it. See Consistency Models for what “a few seconds behind” means in practice.

10. Circuit Breaker: stop one failure from taking everything down

The problem. The payment gateway is down. Every checkout request waits 30 seconds for a timeout. PHP-FPM workers pile up waiting on it, the pool fills, and now the homepage is down too, because there are no workers left to serve it. One dead dependency took the whole app with it.

The pattern. Track failures, and once there are too many, stop calling the dependency for a while. Fail fast instead of failing slowly. A breaker has three states:

  • CLOSED: normal. Calls go through, and failures are counted.
  • OPEN: too many failures in a row. Calls fail immediately without touching the network. Users get a quick “payments are temporarily unavailable” instead of a 30-second spinner, and your workers stay free.
  • HALF-OPEN: after a cooldown, let one trial call through. If it succeeds, go back to CLOSED. If it fails, go back to OPEN and wait again.
final class CircuitBreaker
{
    public function __construct(
        private string $name,
        private int $threshold = 5,
        private int $cooldown = 30,
    ) {}

    public function call(callable $fn): mixed
    {
        if (Cache::has("cb:{$this->name}:open")) {
            throw new CircuitOpen($this->name);                // OPEN: fail fast
        }

        try {
            $result = $fn();
            Cache::forget("cb:{$this->name}:failures");        // success: CLOSED again
            return $result;
        } catch (Throwable $e) {
            $failures = Cache::increment("cb:{$this->name}:failures");
            if ($failures >= $this->threshold) {
                // trip. When this key expires, the next call is the HALF-OPEN trial.
                Cache::put("cb:{$this->name}:open", true, $this->cooldown);
                Cache::forget("cb:{$this->name}:failures");
            }
            throw $e;
        }
    }
}

$breaker = new CircuitBreaker('paymongo');
$breaker->call(fn () => $gateway->charge($order));

This is a deliberately minimal version. A production one also limits HALF-OPEN to a single trial call, so a recovering service isn’t hit by everyone at once, and counts failures in a time window instead of in a row. Pair it with short timeouts, because a breaker can’t help if every call still waits 30 seconds before it counts as a failure. More on this in Designing for Failure.

How they fit together

None of these live alone in a real backend. Follow a single checkout request through a reasonably mature Laravel app:

  1. Middleware (Decorator) authenticates and rate-limits the request.
  2. The controller sends a PlaceOrder command (CQRS write side).
  3. A Strategy picks the right discount for this customer.
  4. A Factory returns the tenant’s payment gateway, an Adapter over the provider’s SDK, wrapped in a Circuit Breaker.
  5. The order is saved through a Repository.
  6. OrderPlaced fires and Observers reserve stock, send the email and update the dashboard’s read model.
  7. The logger and config underneath all of it are container Singletons.

That’s every pattern on this list, and none of them is there for decoration. Each one solves a specific problem the app actually ran into.

The part that matters

If you remember one thing, make it this: learn the problem, not the definition. A pattern applied to a problem you don’t have is just indirection, with more files, more jumps and more things to explain to the next developer. Most of these should arrive after the pain shows up: the third payment provider, the fifth discount rule, the first outage caused by a slow dependency.

The senior move isn’t knowing all ten. It’s looking at a messy piece of code and recognising, “oh, this is the problem Strategy solves,” and just as often, “this is fine as an if statement. Leave it alone.”