Swappable layers: why every external service belongs behind a contract
Payment providers, SMS, databases and AI models all change eventually. The ports-and-adapters pattern turns that change into replacing one file instead of rewriting the product. A plain explanation with real examples.
Every software product depends on things outside itself: a payment gateway, an SMS service, a database, an AI model. They share one trait — sooner or later they change. Prices move, a service goes down, a better option appears.
The question isn't whether they will change. It is how much that change will cost you.
The problem with direct dependency
The easiest thing is to call the external service wherever you need it. The ordering code talks to the payment gateway itself. The sign-up code sends its own SMS.
That is fast in week one. The trouble comes when the service's name and details are scattered across dozens of places, and replacing it means touching all of them.
Ports and adapters
The solution has two parts:
- Port: a contract stating what the system needs, without saying who does it. For example, "send a notification for a new request".
- Adapter: an implementation of that contract for one specific service — a Telegram adapter, an email adapter, an SMS adapter.
The product's core logic only knows the port. Which adapter is active is decided in one place, by configuration.
An example from this site
When the contact form on this site receives a request, it has to notify me. The contract is one line:
notifyNewInquiry(inquiry) → delivered / not deliveredBehind it sit several adapters: log, Telegram, Bale, email and browser push. They can run together. The form's code knows none of them.
The database follows the same pattern: a lightweight file-based database in development, PostgreSQL in production. Switching is a single environment variable.
Three practical benefits
- Cheap replacement. Changing a service means writing a new adapter. The rest stays untouched.
- Development without dependencies. The product runs and is testable without an account at any external service, because a local adapter exists.
- Testability. In tests, a fake adapter stands in for the real service, so tests are fast and reliable.
Where you don't need it
The pattern is for external boundaries. Wrapping every internal function in a contract only makes code harder to read. My rule:
- Anything outside your control, or likely to be replaced → behind a port.
- The product's own internal logic → direct and simple.
Designing a good contract
- Write it in the product's language, not the service's. The contract is "start a payment", not "POST to this URL".
- Keep it small. Only what you actually need.
- Include failure. What happens when the service doesn't respond belongs in the design, not as a surprise in production.
- Keep secrets in the adapter. Keys and endpoints must not leak into core logic.
How it fits the rest of the architecture
This pattern is the backbone of final architecture from day one, a lean implementation to start: boundaries stay stable while what sits behind them can start simple. In AI products, the same logic makes model switching and cost control possible.
The takeaway
External services are guests in your product, not its pillars. If each guest waits behind its own door, arrivals and departures don't upset the house.
For an architecture that grows with the product, see architecture and technical planning.