Route Handler vs Service vs Repository — Where Business Logic Should Live

The rule for publishing an article ends up in four files — the web handler, a nightly job, a CLI, a webhook — and one of them never gets the fix. This is the whole extraction, line by line: what belongs in the handler, what belongs in the...

Banner

Prefer to watch? ▶ Full walkthrough (9 min) ⚡ The short version ✈ Telegram

The rule for publishing an article lives in four different files in your project. A bug report comes in. Which of the four did you forget to fix?

It starts with a very reasonable decision. Someone clicks Publish in the browser, so you write the publishing logic right there, inside the code that answers that button. It works. Then a scheduled job needs to publish overnight. Then a command line tool. Then a webhook from the payment provider. Every time, you copy the same logic into the new place. Now that logic exists in four copies — and last month somebody fixed a bug in three of them.

First, what this question actually is

This is not a database problem and it is not a problem with your framework. It is application architecture, and inside that, one specific question: which kind of code is allowed to live in which file. Most people call it layering.

There are three places where code can live:

LayerOwnsMust never contain
Handler (controller, route)HTTP: parse the body, validate shape, auth, status codes, call one servicebusiness rules, SQL
Service (use case)the decisions: rules, invariants, orchestration, the transaction boundaryHTTP objects, SQL
Repositorydata access: queries, the ORM, mapping rows to domain objectsbusiness decisions

And three questions about the walls between them: which layer is allowed to use which, where the database transaction starts, and which of the three you can test without starting a web server.

You only feel any of this when your system has more than one way in. With a single endpoint you will never notice it. Most systems have four.

Is this just SOLID?

No — and the confusion is worth clearing up, because the two get mixed constantly.

SOLID is about the shape of one class. Layering is about the shape of a codebase.

Here is the difference in one function. Imagine a function that makes a cup of coffee:

def make_coffee(request):                     # reads the buttons  (handler)
    size = request.form["size"]
    shots = 2 if size == "large" else 1
    if request.user.visits > 10:              # decides the recipe  (service)
        shots += 1
    beans = db.query("SELECT grams FROM hopper WHERE bean = ?", size)   # storage
    db.execute("UPDATE hopper SET grams = grams - ?", 7 * shots)
    return {"status": "brewing", "shots": shots}                       # buttons again

Three different kinds of work in one function: it reads the buttons, it decides the recipe, it takes beans out of storage.

SOLID's answer is to split this into three classes and give each class one job. So you do that — and notice what did not change. The recipe class still talks to the database by itself. The button class still adds the loyalty shot, because the recipe class does not.

Layering asks a different question: which class is allowed to know about what. The button class is not allowed to know the recipe. The recipe class is not allowed to know where the beans are stored — it only asks for beans. Keep that rule and the phone order and the morning timer make the same cup of coffee, with the same recipe.

You can follow SOLID perfectly and still have your publishing rule sitting inside a route handler. You can also have clean handler/service/repository layers full of classes that break every SOLID letter. They overlap in exactly two places: the fat handler doing three jobs at once (single responsibility, applied to a function), and the service not importing the database but receiving a repository (dependency inversion).

The handler everyone writes first

@app.post("/articles/{id}/publish")
def publish(id: int, db=Depends(get_db)):
    a = db.query(Article).get(id)
    if a is None:      raise HTTPException(404)
    if a.published_at: raise HTTPException(409)
    a.published_at = datetime.now(timezone.utc)
    db.commit()
    cache.delete(f"feed:{a.author_id}")
    for s in db.query(Sub).filter_by(author=a.author_id):
        mailer.send(s.email, a.title)
    return {"id": a.id, "status": "live"}

Eleven lines, and every one of them works. That is exactly why nobody touches it. It is not broken — it is just the only place this knowledge lives, and it is welded to the web.

Look at the same eleven lines by the job each one does:

  • 3 lines talk to the web — reading the request, returning a status code
  • 3 lines decide — is this allowed, and what happens next
  • 5 lines reach outside — the database, the cache, the mail server

Three unrelated jobs, in one function, in whatever order you happened to type them, and nothing in the file marks the boundaries. That is why copying it felt fine: you could not see that you were copying three different things at once.

The trap: a folder is not a boundary

This one catches good engineers. You read about layers, you make a services/ folder and a repositories/ folder, you move code around. Did any logic actually move?

class ArticleService:
    def __init__(self, db):
        self.db = db

    def get(self, id):
        return self.db.query(Article).get(id)

    def save(self, a):
        self.db.commit()

# It forwards. It decides nothing.
# You added a file, not a boundary.

There is not a single if in the whole class. So here is the test: if you deleted this file and called the database directly, would anything behave differently? No. Then it was never a layer — it was a redirect with extra typing.

A layer earns its place by owning a decision. What creates a boundary is knowledge that lives on one side of it and nowhere else.

The real extraction

# no HTTP in here. no SQL in here.
def publish_article(articles, notifier, clock, article_id):
    a = articles.get(article_id)
    if a is None:      raise ArticleNotFound(article_id)
    if a.published_at: raise AlreadyPublished(article_id)
    if not a.title:    raise MissingTitle(article_id)

    a.published_at = clock.now()
    articles.save(a)
    notifier.article_published(a)
    return a

One function. It is handed three things — somewhere to load and store articles, something that sends notifications, and a clock — and then it does its only job: it decides.

Look closely at what it raises when it refuses. Not a 404. That is a web answer, and a cron job at three in the morning cannot answer a web request. It raises its own errors in its own vocabulary: ArticleNotFound, AlreadyPublished.

The other half of the move is the repository:

class ArticleRepository:
    def __init__(self, db):
        self.db = db

    def get(self, article_id):
        return self.db.query(Article).get(article_id)

    def save(self, article):
        self.db.add(article); self.db.commit()

    def due_for_publish(self, before):
        return self.db.query(Article).filter(
            Article.scheduled_at <= before).all()

Every query in one place, and named for the question rather than for the SQL: due_for_publish, not select_where_scheduled_at_lt. That is what lets you rewrite the query, add an index, or move to a different database without one line of your rules changing.

What is left at the doors

@app.post("/articles/{id}/publish")
def publish(id: int, svc=Depends(publishing)):
    try:
        return ArticleOut.from_orm(svc(id))
    except AlreadyPublished: raise HTTPException(409)
    except ArticleNotFound:  raise HTTPException(404)

# the cron job. same rule. no HTTP at all.
for a in articles.due_for_publish(clock.now()):
    publish_article(articles, notifier, clock, a.id)

Five lines at the web door. Take the request, call the service, turn the answer into a response — and map the service's errors to status codes. That translation is the handler's entire job, and it is the only place in the codebase that knows what a status code is.

Two lines at the cron door, calling the exact same function. No copy. No paste.

The rule that matters more than the folder names

The calls only ever point downward: handler → service → repository. Never the other way. A repository that reaches back up into your rules is not a repository; it is the same tangle wearing a new name.

The payoff, measured in diffs

A new rule arrives: a premium article now needs an editor's sign-off.

  • Before: four edits, in four files, four reviews, four chances to miss one.
  • After: one function, in one file — and the website, the timer, the command line and the webhook all pick it up for free, because none of them was ever holding a copy.

Three numbers hold the whole argument: 4 copies of the rule before, 1 file that owns it after, 0 lines of database code inside the service. That last one is the one people skip, and it is what keeps the other two true — the moment a query leaks into your rules, the rules are pinned to that database.

The whole thing

  • Handler: turn a request into a call, and an error into a status code.
  • Service: the rules — and it may not know the web or the database.
  • Repository: the queries — and it may not know the rules.
  • If a layer only forwards, delete it. It is not earning its file.

Layers are not about having more folders. They are about every rule having exactly one home, so a fix cannot land in three places out of four.

And the honest caveat: for a three-endpoint CRUD app this is overhead. The split earns its keep the moment a second caller or a second developer appears — which, if the thing works, is sooner than you think.


Watch the full walkthrough on YouTube for all of it in one pass, or the short version if you have three minutes.

Route Handler vs Service vs Repository — Where Business Logic Should Live | Software Engineer Blog