slowlp
← Blog
Method 2026.06.22 · 9 min read

Validate at Every Boundary: Schemas and Layered Defense

Don't ship your models as-is; validate at each layer with a different responsibility

Method

Validate at Every Boundary: Schemas (DTOs) and Defense in Depth

Don’t ship your models as-is; validate at each layer with a different responsibility

Never return your table model directly as an API response. The first time I heard that, it sounded like an overly strict rule. It’s the same data anyway, so why build a separate object for it? But you get it once you cause an incident. A model can carry fields like secret_name that must never leave the building. Returning the model directly means those fields get serialized right along with everything else and leak out to the client. A DTO is the device that structurally prevents that kind of accident.

A DTO is a Separate Object You Stand at the Boundary

A DTO (Data Transfer Object) — this guide calls it a schema — is an object used only at the boundary where the API exchanges data with the outside world. We deliberately keep it separate from the table model on the inside.

DTOs split by purpose: request DTOs for incoming data (Create, Update) and response DTOs for outgoing data (Public). Shared fields live in {feature}Base, and the rest inherit from it to avoid duplication.

class PortfolioStockBase(BaseSchema):
    target_weight: Decimal | None = Field(default=Decimal("0.00"))
    rebalance_yn: str = Field(default="Y")
    memo: str | None = Field(default=None)

# Incoming: every field we need
class PortfolioStockCreate(PortfolioStockBase, BaseCreateSchema[PortfolioStocks]):
    portfolio_id: int
    stock_id: int

# Outgoing: only what's safe to expose
class PortfolioStockPublic(PortfolioStockBase):
    id: int
    portfolio_id: int
    stock_id: int

The response DTO is the heart of it. Public picks out only the fields that are safe to send. No matter what else gets bolted onto the model, only what’s listed here goes out in the response. It’s not that you might forget a field and leak it — it’s a whitelist that only lets through what you explicitly listed.

Why Validating in One Place Isn’t Enough

When the conversation turns to validation, the usual instinct is this: just filter hard at the front door once, and you’re done, right? You’re not. Each layer can verify different things. That’s why this guide uses defense in depth. A request gets checked four times on its way from arrival to the database.

DTO layer — the first line of defense. It checks whether the incoming data even makes sense on its own. Is the price greater than zero? Is this a valid email format? Is the password at least 8 characters? You handle this with Pydantic’s Field or @model_validator.

class UserCreate(SQLModel):
    email: EmailStr
    password: str = Field(min_length=8)
    age: int | None = Field(default=None, gt=0, le=150)

What you can stop here is exactly format and value range — nothing more. The DTO knows nothing about the database. So you need the next checkpoint.

Router/service layer — this checks whether the data lines up with reality. Does the ID in the request actually exist in the DB? Does this person have permission to touch it? A well-formatted ID that doesn’t exist is something a DTO can never catch. You only know by querying, and querying is the service’s job.

post = await post_repository.get(post_id, session)
if not post:
    raise NotFoundException("Post not found.")
if post.owner_id != user_id:
    raise ForbiddenException("You don't have permission to edit this post.")

Model layer — the object checks for itself whether it’s in a state where this operation is allowed. An unpaid order can’t ship; you can’t buy if you’re short on points. This isn’t an input problem, it’s a state problem about the object, so the object itself knows best.

def ship_order(self):
    if self.status != "paid":
        raise ConflictException(f"An unpaid order (status: {self.status}) cannot be shipped.")
    self.status = "shipped"

There’s a grain to the exception choice here, too. A state conflict raises ConflictException (409); a plain input-value violation raises BadRequestException (400). “This doesn’t match the server’s current state” and “the value you sent is wrong” are two different stories.

And the last one is the DB. Constraints like NOT NULL and UNIQUE hold the final line.

Don’t Carry the Same Responsibility Twice

The point is that each layer only checks what it can see. The DTO checks format, the service checks existence and permission, the model checks state, the DB checks integrity. The lower layers don’t skip validation just because the layer above caught something — the lower layers catch what the upper layers fundamentally can’t know about. The validation isn’t duplicated; the responsibility is different.

At first, checking in four places looked inefficient. But once I thought of each layer as owning the information only it can know from where it sits, it clicked into place. If you try to block everything at the front door, the front door has to know about the DB — and the layering collapses.

From the project
Stock Portfolio App (MyFolio) LAB
A portfolio-first investing app — no need to analyze individual stocks, just connect your KIS brokerage account and let the app handle order planning. Running privately on my home server.
View →
COMMENTS