DEV Community

Cover image for Abstraction Is Not a Pattern. It's a Discipline
Lepres KIKOUNGA
Lepres KIKOUNGA

Posted on • Originally published at lepresk.com

Abstraction Is Not a Pattern. It's a Discipline

Every time you inject a database driver directly into a service class, you make a decision. The problem is that most teams don't know they're making it.

The code runs. The feature ships. The sprint closes. Tight coupling doesn't fail loudly. It accumulates quietly, in every service that knows the shape of your database connection, in every class that imports the payment SDK directly, in every function that calls an external API with nothing in between.

By the time you feel it, it's everywhere.

Your service has one job

A PaymentService should know how to process a payment. It should not know that the payment history lives in a PostgreSQL table, accessed through a specific driver, on a specific connection pool.

But that's what happens when you write this:

// ❌
class PaymentService {
  async processPayment(userId: string, amount: number) {
    const db = new DatabaseConnection(process.env.DB_URL);
    await db.query('INSERT INTO transactions ...', [userId, amount, 'pending']);

    const stripe = new Stripe(process.env.STRIPE_KEY);
    const charge = await stripe.charges.create({ amount, currency: 'usd' });

    await db.query('UPDATE transactions SET status = ? ...', ['completed', charge.id]);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is not a payment service. It is a function that simultaneously orchestrates a database driver, raw SQL, and a third-party SDK. The business logic and the infrastructure are the same thing.

Swap PostgreSQL for MongoDB: rewrite. Replace Stripe with another provider: rewrite. Write a unit test without a live database and a real API key: you can't.

Every external dependency the service touches is a nail through the business logic. And those nails multiply.

The Repository pattern: your database has no business being visible

The Repository pattern places a contract between your application logic and your persistence layer. The contract speaks the language of your domain. The implementation speaks the language of the database.

// The contract: your application only knows this
interface UserRepository {
  findById(id: string): Promise<User | null>
  save(user: User): Promise<void>
  delete(id: string): Promise<void>
}

// One implementation
class PostgresUserRepository implements UserRepository {
  async findById(id: string) {
    const row = await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
    return row ? this.toUser(row) : null;
  }
}

// Another implementation, same contract
class MongoUserRepository implements UserRepository {
  async findById(id: string) {
    const doc = await this.collection.findOne({ _id: id });
    return doc ? this.toUser(doc) : null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The service that needs a user receives a UserRepository. It calls findById. What happens on the other side is invisible to it.

That separation is not cosmetic. It is the boundary between your business logic and your infrastructure. Cross it deliberately, or not at all.

The Gateway pattern: external APIs are implementation details too

The same problem applies to every external service your application depends on: payment providers, SMS gateways, email services, third-party APIs. The Gateway pattern wraps the external service behind a contract your application controls.

// The contract
interface SmsGateway {
  send(to: string, message: string): Promise<void>
}

// Twilio today
class TwilioSmsGateway implements SmsGateway {
  async send(to: string, message: string) {
    await this.client.messages.create({ to, body: message, from: this.sender });
  }
}

// A different provider tomorrow, same contract
class AfricasTalkingSmsGateway implements SmsGateway {
  async send(to: string, message: string) {
    await this.sms.send({ to: [to], message, from: this.sender });
  }
}
Enter fullscreen mode Exit fullscreen mode

Your notification service receives an SmsGateway. It calls send. Whether the message goes through Twilio, Africa's Talking, or any other provider is an infrastructure decision that lives entirely outside the business logic.

A provider changes pricing. You write a new class and swap the wiring. Nothing in the service moves.

The Adapter: bridging your contract and the outside world

When you look inside a Gateway implementation, there is usually a gap. Your contract speaks your domain's language. The SDK speaks its own. The Adapter is what closes that gap.

Take a payment provider. Its SDK returns a raw response object, with its own field names, its own error structure, its own status codes. None of that belongs in your service. The Adapter translates.

// What your domain expects
interface ChargeResult {
  success: boolean
  transactionId: string
  amount: number
}

interface PaymentGateway {
  charge(amount: number, currency: string): Promise<ChargeResult>
}

// The Adapter: translates Stripe's world into yours
class StripePaymentGateway implements PaymentGateway {
  async charge(amount: number, currency: string): Promise<ChargeResult> {
    try {
      const charge = await this.stripe.charges.create({
        amount: Math.round(amount * 100), // Stripe works in cents
        currency,
        source: 'tok_visa',
      });

      // Stripe's response adapted into your domain type
      return {
        success: charge.status === 'succeeded',
        transactionId: charge.id,
        amount: charge.amount / 100,
      };
    } catch (error) {
      return { success: false, transactionId: '', amount: 0 };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The service receives a ChargeResult. It knows nothing about Stripe's status codes, its amount format, or its error model. If Stripe's API changes tomorrow, you fix the Adapter. The contract stays intact. The service doesn't know anything happened.

Every time you write a Gateway implementation, you are also writing an Adapter. The distinction is worth naming deliberately, because it clarifies the responsibility: one side is your domain, the other side is theirs. The Adapter is the translator standing between them.

The Wrapper: stacking behavior without touching the logic

Once you have a contract, something interesting becomes possible. You can add behavior to an implementation without modifying it, by wrapping it with another class that implements the same contract.

This is the Decorator pattern, often called a Wrapper in this context. It is particularly useful for cross-cutting concerns: logging, retry logic, caching, circuit breakers. Behavior that applies regardless of which implementation sits underneath.

// A wrapper that adds retry logic to any SmsGateway
class RetryingSmsGateway implements SmsGateway {
  constructor(
    private inner: SmsGateway,
    private maxAttempts: number = 3
  ) {}

  async send(to: string, message: string): Promise<void> {
    let lastError: Error | null = null;

    for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
      try {
        await this.inner.send(to, message);
        return;
      } catch (error) {
        lastError = error as Error;
        if (attempt < this.maxAttempts) {
          await this.delay(attempt * 500); // exponential-ish backoff
        }
      }
    }

    throw lastError;
  }

  private delay(ms: number) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// A wrapper that adds logging
class LoggingSmsGateway implements SmsGateway {
  constructor(private inner: SmsGateway, private logger: Logger) {}

  async send(to: string, message: string): Promise<void> {
    this.logger.info(`Sending SMS to ${to}`);
    try {
      await this.inner.send(to, message);
      this.logger.info(`SMS delivered to ${to}`);
    } catch (error) {
      this.logger.error(`SMS failed to ${to}`, error);
      throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

These wrappers compose. You stack them at the wiring level, and each layer adds its behavior without knowing what's underneath it.

// Wiring: Twilio, with logging, with retry
const smsGateway = new RetryingSmsGateway(
  new LoggingSmsGateway(
    new TwilioSmsGateway(twilioClient),
    logger
  ),
  3
);
Enter fullscreen mode Exit fullscreen mode

The service still receives an SmsGateway. It still calls send. It knows nothing about retries, logging, or Twilio. All of that is composed outside, at the application level, without touching a single line of business logic.

Dependency Injection: contracts only work if you use them right

Defining interfaces is half the work. The other half is never letting the service decide which implementation it receives.

This is what Dependency Injection solves. The service declares what it needs through its constructor. Something outside, typically a DI container or the composition root of your application, decides which concrete implementation gets injected.

// ✅ The service declares its dependencies through contracts
class NotificationService {
  constructor(
    private sms: SmsGateway,
    private users: UserRepository
  ) {}

  async notifyUser(userId: string, message: string) {
    const user = await this.users.findById(userId);
    if (!user) return;
    await this.sms.send(user.phoneNumber, message);
  }
}

// The wiring happens once, at the application level
const service = new NotificationService(
  new RetryingSmsGateway(new TwilioSmsGateway(twilioClient), 3),
  new PostgresUserRepository(dbConnection)
);
Enter fullscreen mode Exit fullscreen mode

The service never instantiates its dependencies. It never imports a driver. It never reads an environment variable. It receives everything through its constructor and trusts the contracts.

That single rule, applied consistently, is what makes a codebase testable.

Testability: the return you feel immediately

When every external dependency is hidden behind a contract, testing business logic becomes straightforward. No running database. No live API key. No network call. You inject mocks.

// A mock that replaces a third-party SDK entirely
class MockSmsGateway implements SmsGateway {
  public sent: Array<{ to: string; message: string }> = [];

  async send(to: string, message: string) {
    this.sent.push({ to, message });
  }
}

class MockUserRepository implements UserRepository {
  private users: User[] = [
    { id: '1', phoneNumber: '+242068511358', name: 'Test User' }
  ];

  async findById(id: string) {
    return this.users.find(u => u.id === id) ?? null;
  }

  async save(user: User) { this.users.push(user); }
  async delete(id: string) { this.users = this.users.filter(u => u.id !== id); }
}

// The test: pure logic, zero infrastructure
it('sends a notification to the correct user', async () => {
  const sms = new MockSmsGateway();
  const users = new MockUserRepository();
  const service = new NotificationService(sms, users);

  await service.notifyUser('1', 'Your order has shipped.');

  expect(sms.sent).toHaveLength(1);
  expect(sms.sent[0].to).toBe('+242068511358');
});
Enter fullscreen mode Exit fullscreen mode

The test runs in milliseconds. It never touches a database or a network. And it tells you something real about the behavior of your code, not about the availability of your infrastructure.

That's what contracts enable. Not just flexibility. Confidence.

"It will never change" and when that's honest

There is a legitimate case for tight coupling. It sounds like this: "This is a throwaway script. It hits one table. It will be deleted in three months."

That argument is valid when it's true, and when it's a decision.

The problem is that most tight coupling in production didn't start as a deliberate choice. It started as a shortcut that wasn't meant to last, in a service that was supposed to be temporary, in a project where "we'll clean this up later" was sincere and never happened.

If you consciously decide that a piece of code has no future, tight coupling is a valid trade-off. Write that decision into a comment. Own it.

Everything else is an accident waiting to compound.

The discipline is the part that doesn't feel productive

Repository, Gateway, Adapter, Wrapper: the patterns are documented, well understood, and widely available. The problem is not knowledge. Most developers who write tight coupling know exactly what the alternative looks like.

The problem is the moment of decision. The feature is due. The driver is right there. Writing the interface feels like ceremony with no visible output.

Discipline is what happens in that moment: defining the contract before writing the implementation, not because the pain is immediate, but because you've seen enough codebases to know exactly where the shortcut leads.

It leads to a service you can't test without a live database. To a provider migration that touches forty files instead of one. To a codebase where swapping an external dependency is a project, not an afternoon.

Patterns give you the vocabulary. Discipline is what makes you reach for it before you feel the pain.

Top comments (0)