There's a specific kind of frustration that comes from reading documentation that is technically accurate but leaves you no clearer than when you started.
You follow the getting started guide. You run the commands. The project scaffolds. You look at the folder structure and the files inside it, and you can see that something has been generated — there's code everywhere, there are YAML files and Dart files and a directory for the server and another for the client and another called shared — and none of it quite connects into a coherent picture of what you're actually supposed to do next.
This is a common experience with Serverpod, and it has very little to do with the framework being poorly designed. Serverpod is, in fact, well-designed. The confusion comes from something else: most people approach it looking for a step-by-step tutorial, when what they actually need first is a map.
A map of how the pieces relate. A mental model that makes each individual concept land in the right place. Once you have that, the documentation stops feeling like noise and starts making sense. The folder structure looks intentional. The code generation feels logical. The workflow becomes repeatable.
This article is that map.
Start With the Big Picture
Before touching any specific concept, it helps to understand what Serverpod is actually trying to do at the highest level.
Serverpod is a full-stack Dart framework. That phrase gets used loosely, so it's worth unpacking what it means in concrete terms. When you build with Serverpod, you write one codebase in Dart that spans two environments — your server and your client. Those two environments don't communicate through a manually maintained contract. They communicate through code that Serverpod generates from a single shared definition.
That's the central idea. One definition, two environments, generated bridge.
Everything in Serverpod — the project structure, the YAML models, the endpoint system, the code generation step — exists in service of that central idea. When you understand what problem each piece is solving, the pieces stop feeling arbitrary.
The Three Layers You Need to Know
Serverpod's architecture organises itself around three layers, and understanding how they relate to each other is the foundation of the mental model.
The first layer is the definition layer. This is where you describe your data and your server's capabilities. Models are defined in YAML files. Endpoints are defined as Dart classes in the server package. The definition layer is the source of truth — it's where you make decisions, and everything else follows from what you put here.
The second layer is the generated layer. This is code that Serverpod produces from your definitions. You don't write it. You don't edit it. You run serverpod generate and it appears — a Dart class for each model, serialisation logic, the client stubs that your Flutter app uses to call your endpoints. The generated layer is the bridge between your server and your client, and it's rebuilt every time your definitions change.
The third layer is the implementation layer. This is where you write your actual application logic — the bodies of your endpoint methods, any services or helpers you build, your business rules. This layer reads from the generated layer but sits above it. It's where your code lives.
Three layers: define, generate, implement. Everything in a Serverpod project fits into one of them.
Models: The Foundation of Everything
In Serverpod, a model is a YAML file. That's the first thing to accept, especially if you're coming from Flutter where your models are Dart classes you write by hand.
A Serverpod model looks like this:
class: Message
table: messages
fields:
content: String
createdAt: DateTime
authorId: int
That definition tells Serverpod several things at once. It tells it the name of the Dart class to generate. It tells it that this model maps to a database table called messages. It tells it the fields, their types, and by extension, the columns that table will have.
From that one YAML file, Serverpod generates a complete Dart class with all the fields typed correctly, serialisation and deserialisation methods, and the database mapping that allows the ORM to read from and write to the table. You get all of that from six lines of YAML.
The deeper significance of this is not the reduced typing, though that's real. It's that the model definition becomes the single source of truth for a piece of data across your entire system. Your Flutter app, your server logic, and your database all work from the same definition. When you change the model, you change it in one place, regenerate, and the change propagates everywhere. There's no synchronisation to maintain manually. There's no drift between what your server produces and what your client expects.
This is the idea that makes the model layer feel different from just writing Dart classes. It's not a convenience. It's an architectural guarantee.
Endpoints: Your Server's Public Interface
If models are the shape of your data, endpoints are the shape of your server's behaviour.
An endpoint in Serverpod is a Dart class that extends Endpoint. Each public method on that class becomes something your client can call directly. There's no routing configuration to write. There's no HTTP method to specify. You write a Dart method, and Serverpod makes it callable from your Flutter app.
A simple endpoint looks like this:
class MessageEndpoint extends Endpoint {
Future<List<Message>> getMessages(Session session) async {
return await Message.db.find(session);
}
Future<Message> createMessage(Session session, String content) async {
final message = Message(
content: content,
createdAt: DateTime.now(),
authorId: session.auth.userId!,
);
return await Message.db.insertRow(session, message);
}
}
A few things are worth noting here. The Session parameter is always first — it's Serverpod's way of passing you the context you need for database access, authentication information, and logging. The return types are your model classes directly. The database calls are clean and typed. You're writing Dart, not constructing SQL strings or mapping response maps to objects.
When you run serverpod generate, Serverpod inspects this class and generates a corresponding client class in your Flutter package. That generated client has the same method names, the same parameter types, the same return types. Calling client.message.getMessages() from your Flutter app looks and behaves exactly like calling a local method — because from your Flutter code's perspective, that's essentially what it is.
This is where the full-stack Dart idea becomes tangible. The boundary between client and server exists in the infrastructure, but it largely disappears from your code.
Sessions: The Context That Runs Through Everything
The Session parameter deserves its own moment because it's one of the concepts that confuses developers who are new to Serverpod the most.
Every endpoint method receives a session as its first argument. You can't opt out of this. And at first it feels like overhead — a thing you're forced to pass around without fully understanding why.
The session is Serverpod's equivalent of a request context. It knows who the current user is, if anyone is authenticated. It provides access to the database through the ORM. It holds the logging context so that anything you log inside that method is attributed to that request. It carries the configuration your server was initialised with.
Think of it as the thread that connects a single incoming request to all the resources your server has available. Each request gets its own session, which means each request gets its own isolated context. There's no global state to worry about, no shared mutable data that could produce race conditions. The session hands you everything you need for that one request, and when the request is done, the session goes away.
Once you internalise the session as "the context of this request", the pattern becomes natural. You pass it to database calls because the database needs to know which request's transaction it's participating in. You pass it to authentication checks because those checks need access to who made the request. It stops feeling like overhead and starts feeling like clarity.
The Database Layer: Less Magic Than It Looks
Serverpod includes a database layer built around its own ORM, and this is often where developers get tangled up because it can look like magic until you understand the pattern.
The ORM is generated from your model definitions. When you define a model with a table field, Serverpod generates a corresponding database class accessible through YourModel.db. That class has methods for common database operations — find, findById, insertRow, updateRow, deleteRow — all typed to your model.
Queries are built through a type-safe expression system rather than raw strings:
final recentMessages = await Message.db.find(
session,
where: (m) => m.createdAt.isAfter(DateTime.now().subtract(Duration(hours: 24))),
orderBy: (m) => m.createdAt,
orderDescending: true,
limit: 50,
);
The where parameter gives you a typed reference to your model's fields, so you can't accidentally reference a field that doesn't exist or pass the wrong type. If your model changes and a field you're querying is renamed or removed, the query breaks at compile time, not at runtime when a user hits that code path.
Migrations sit alongside this. When your models change — a new field, a renamed table, a relationship added — you run serverpod create-migration and Serverpod generates a migration file that describes the change. You run the migration against your database, and the schema updates. Your code and your database stay in sync.
The Project Structure, Revisited
With the mental model in place, the folder structure that looked overwhelming at the beginning now has a logic to it.
A Serverpod project has three packages: one for the server, one for the client, and one called shared. The server package contains your endpoint classes and your server-side logic. The client package is almost entirely generated — it's the code your Flutter app imports to call your endpoints. The shared package contains the generated model classes and anything that legitimately needs to exist in both environments.
When you run serverpod generate, it reads your model YAML files and your endpoint classes, and it writes into the client and shared packages. You own the server package. Serverpod owns the generated parts of the other two.
That boundary — what you write versus what Serverpod generates — is the key to understanding which files you should touch and which you should leave alone. Anything in a file that starts with a generation comment is not yours to edit. Everything else is.
The Workflow That Makes It Repeatable
Once the mental model is solid, the development workflow becomes a repeatable loop.
You start in the definition layer. You decide what data you need — add a field to a model, define a new model, or add a method to an endpoint. You make that change in the YAML or in the endpoint class. Then you run serverpod generate. The generated layer updates — new client methods, updated model classes, serialisation logic that reflects your change. Then you move to the implementation layer and write the actual logic for whatever you've defined.
Define. Generate. Implement.
That loop is Serverpod development. The concepts involved — models, endpoints, sessions, migrations — are the vocabulary you use within that loop. But the loop itself is simple, and it's consistent. You run through it the same way whether you're adding a field to an existing model or building an entirely new feature from scratch.
Where the Map Takes You
A mental model is not the same as experience. Running through the define-generate-implement loop a few times will teach you things no article can, because the learning that comes from making a mistake and tracing it back to its source is different from the learning that comes from reading about the mistake in advance.
But having the map before you start means you're not lost when the unexpected happens. You know which layer the problem lives in. You know where to look. You know what a well-structured Serverpod project looks like, so you can recognise when yours is drifting away from it.
Serverpod is not a simple framework, but it is a coherent one. The complexity it introduces is proportional to the problems it's solving — and the problems it's solving are real ones that any production backend application has to deal with. The boilerplate it generates is boilerplate you would have written yourself, less reliably, in a framework that didn't have strong opinions about how it should look.
That's the trade it's asking you to make. Learn the mental model, trust the structure it gives you, and let the generated layer do the work it was designed to do.
The rest is just Dart.
