Layers and boundaries
Keeping domain logic away from storage and interfaces.
01The intuition
The problem it solves
A URL shortener starts as one class. The method that creates a short link checks the input, builds the code, writes a row with SQL, and formats the JSON the web page needs, all in forty lines. Then the team wants a command-line tool that does the same without a web page, a test that runs without a database, and a switch from SQL to a key-value store. All three are impossible without rewriting the method, because the rules, the storage and the presentation are tangled in one place.
class Shortener:
def create(self, request: dict) -> str:
url = request["url"]
if not url.startswith("http"): return json.dumps({"error": "bad url"})
code = base62(hash(url))[:7]
db.execute("INSERT INTO links VALUES (?, ?)", (code, url))
return json.dumps({"short": "https://sho.rt/" + code})
Build it one piece at a time
Step one: pull out the rules into a class that knows nothing about HTTP or SQL. It takes plain values, applies the rules, and talks to storage only through an interface it defines.
from typing import Protocol
class LinkRepository(Protocol):
def save(self, code: str, url: str) -> None: ...
def find(self, code: str) -> str | None: ...
class ShortenerService: # domain layer
def __init__(self, repo: LinkRepository, codes: "CodeGenerator") -> None:
self.repo, self.codes = repo, codes
def shorten(self, url: str) -> str:
if not url.startswith(("http://", "https://")):
raise InvalidUrl(url)
code = self.codes.next_for(url)
self.repo.save(code, url)
return code
Step two: storage becomes a class that satisfies the interface. There can be several: SQL, an in-memory dictionary for tests, a key-value store later. The service never changes.
class SqlLinkRepository: # infrastructure layer
def save(self, code: str, url: str) -> None:
db.execute("INSERT INTO links VALUES (?, ?)", (code, url))
def find(self, code: str) -> str | None:
row = db.query_one("SELECT url FROM links WHERE code = ?", (code,))
return row[0] if row else None
class MemoryLinkRepository: # for tests and the CLI demo
def __init__(self) -> None: self._d: dict[str, str] = {}
def save(self, code: str, url: str) -> None: self._d[code] = url
def find(self, code: str) -> str | None: return self._d.get(code)
Step three: the web layer only translates. It parses the request, calls the service, and turns the result or the error into JSON. The command-line tool is a second thin translator over the same service.
class HttpHandler: # interface layer
def __init__(self, service: ShortenerService) -> None: self.service = service
def post_create(self, request: dict) -> str:
try:
code = self.service.shorten(request["url"])
return json.dumps({"short": "https://sho.rt/" + code})
except InvalidUrl as e:
return json.dumps({"error": str(e)})
service = ShortenerService(SqlLinkRepository(), Base62Codes())
Why it works
The principle: dependencies point inward. The domain in the middle knows nothing about the outside; the outside (web, database, files) depends on the domain's interfaces. The invariant is that you can run the domain with fakes for every outer piece, which is exactly what a unit test does. Where it breaks: a domain class that imports the database module, SQL strings in the service, JSON in the rules, or a repository interface that leaks its technology (execute_sql as a method). Interviewers test by asking you to swap the storage or add a second interface; the answer should touch one outer class.
The trade-off you must say out loud
Three layers mean three files and an interface for a feature that fit in forty lines, and the first version of anything is faster to write as one class. Layers pay off when the program has two front doors, two storage options, or needs tests that run in milliseconds. Say that you start with the service and the repository interface and add layers as they earn their keep. The follow-up is usually about transactions: which layer begins and commits, since a transaction spans several repository calls.
Where it shows up in the 100 Google problems
- URL shortener (class level): the example above, with the code generator as a second strategy.
- In-memory database with transactions: the query layer, the transaction layer and the storage engine are three layers with clear interfaces.
- Role and permission system: the decision logic is pure; where roles are stored and how requests arrive are outer layers.