All posts
    serverpod
    dart
    architecture
    project-structure
    flutter
    best-practices
    backend

    How to Structure a Serverpod Project That Won't Fall Apart Later

    The getting-started guide gets you running. It doesn't tell you how to organise a Serverpod project that stays coherent as it grows. Here is the structural thinking that the tutorial never covers.

    ·10 min read

    Every Serverpod project starts in roughly the same place.

    You run serverpod create, the CLI scaffolds a server package, a client package, and a Flutter package, and you open the result in your editor. The structure is clean, the example code is minimal, and the path forward seems clear. You follow the getting-started guide, build a few endpoints, define a few models, and start to feel like you understand how the pieces fit together.

    Then the project grows. More models. More endpoints. Business logic that touches multiple models. Utility functions that multiple endpoints need. Database queries that are complex enough to deserve their own layer. Configuration that varies between development and production. The clean scaffolded structure starts accumulating things in places that feel slightly wrong, and at some point you look at the codebase and realise you've been making structural decisions without a structural plan.

    That moment arrives for almost every Serverpod project that makes it past the tutorial stage. The getting-started guide gets you running. It doesn't tell you how to organise a codebase that stays navigable as complexity accumulates. That knowledge is the kind that comes from experience — from watching a Serverpod project grow well and watching another one grow badly, and developing the instinct to tell the difference early.

    This article is that knowledge made explicit.


    Understand What You Own and What You Don't

    Before thinking about how to organise your code, the most important thing to internalise about Serverpod project structure is the boundary between generated code and code you own.

    Generated code lives in the client package and in parts of the shared package. These files are produced by the code generator and overwritten completely every time you run serverpod generate. They don't belong to you. Any change you make to them will be silently lost. Any pattern you try to establish inside them is a pattern that will disappear on the next generation run.

    The code you own lives in the server package — your model YAML files, your endpoint classes, your business logic, your utilities, your configuration. This is where your structural decisions matter, because this is the code that persists, accumulates, and grows.

    Keeping that boundary clear — physically, in your mental model of the project, and in the conventions you establish with your team — is the foundation on which every other structural decision rests. Everything else in this article applies to the server package and the code you own within it.


    The Default Structure Is a Starting Point, Not a Destination

    The server package that serverpod create generates has a straightforward layout. A lib directory contains an src directory, which contains an endpoints directory for your endpoint classes and a generated directory for generated server-side code. The bin directory contains the main.dart entry point.

    That structure is appropriate for a small project. For a project with more than a handful of endpoints and models, it accumulates problems in a predictable way.

    The endpoints directory becomes a flat list of files that grows without organisation as new endpoints are added. When that list reaches ten or fifteen files, navigating it becomes a matter of remembering alphabetical order rather than understanding structure. The relationship between endpoints — which ones are related to the same domain, which ones share logic, which ones touch the same models — is invisible at the file-system level.

    Business logic that doesn't fit neatly into an endpoint class has no obvious home. The first time you need a function that multiple endpoints share, you put it somewhere and move on. The next time, you put it somewhere slightly different. Over time, shared logic scatters across the codebase in ways that become increasingly hard to trace.

    The right response to these problems is not a radical restructuring — it's a deliberate extension of the default structure with additional layers that reflect how the application is actually organised.


    Organise by Domain, Not by Type

    The most durable principle for structuring a Serverpod server package is to organise by domain rather than by technical type.

    The default structure organises by type — all endpoints together, all generated files together. That's readable when the project is small because any two files are close together. It becomes difficult when the project is large because related files are far apart. The endpoint that handles user creation, the service that contains the user creation logic, and the utility that validates user input are in three different directories, with no structural signal that they belong together.

    Domain organisation groups files by what they're about rather than what kind of thing they are. A users directory contains the user endpoint, the user service, and the user utilities. An articles directory contains everything related to articles. An auth directory contains everything related to authentication. Files that belong together live together, and the directory structure becomes a map of the application's concepts rather than a list of its technical components.

    A domain-organised server package might look like this:

    lib/
      src/
        users/
          users_endpoint.dart
          users_service.dart
          users_validator.dart
        articles/
          articles_endpoint.dart
          articles_service.dart
          articles_repository.dart
        auth/
          auth_endpoint.dart
          auth_service.dart
        shared/
          exceptions.dart
          response_helpers.dart
          pagination.dart
    

    This structure scales. Adding a new domain means adding a new directory. Growing an existing domain means adding files within an existing directory. The relationship between files is legible at a glance. A developer new to the codebase can navigate it without a guided tour.


    Keep Endpoints Thin

    The most common structural mistake in Serverpod projects is putting too much logic in endpoint classes.

    The temptation is understandable. An endpoint method receives a session, has access to the database and the current user, and produces a response — it seems like the natural place to put all the work involved in handling a request. And in a small project, that works. A fifteen-line endpoint method that validates input, runs a database query, and returns the result is readable and maintainable.

    The problem arrives when the method grows. When it reaches fifty lines. When the validation logic needs to be shared with another endpoint. When the database interaction becomes complex enough to deserve testing in isolation. When the business rule encoded in the middle of the method needs to change, and the engineer making the change has to read and understand the entire surrounding context before feeling confident about the modification.

    Endpoint classes should do one thing: receive a request and delegate the actual work to a service. The endpoint is responsible for extracting parameters from the session and request, calling the appropriate service method, and returning the result. The service is responsible for the business logic — validation, computation, orchestration of multiple operations. The repository or database layer is responsible for data access.

    That separation looks like this in practice:

    // Endpoint — thin, focused on request handling
    class ArticleEndpoint extends Endpoint {
      Future<Article> createArticle(
        Session session,
        String title,
        String body,
      ) async {
        final userId = await session.auth.authenticatedUserId;
        if (userId == null) throw AuthenticationFailedException();
    
        return await ArticleService.create(
          session,
          title: title,
          body: body,
          authorId: userId,
        );
      }
    }
    
    // Service — contains the actual logic
    class ArticleService {
      static Future<Article> create(
        Session session, {
        required String title,
        required String body,
        required int authorId,
      }) async {
        ArticleValidator.validateTitle(title);
        ArticleValidator.validateBody(body);
    
        final article = Article(
          title: title,
          body: body,
          publishedAt: null,
          authorId: authorId,
        );
    
        return await Article.db.insertRow(session, article);
      }
    }
    

    The endpoint is eight lines. The service contains the logic. Each layer can be understood, tested, and modified independently.


    Make Your Error Handling a Deliberate Decision

    Serverpod endpoint methods can fail in several ways, and the way failures are communicated back to the client matters more than most developers think when they're first building out their endpoints.

    An endpoint can throw an exception, return null, return an empty result, or return a typed error wrapper. Each of these is a different contract with the client, and choosing between them inconsistently — which happens naturally when each endpoint is written without reference to a shared convention — produces a client-side experience where every endpoint needs to be handled differently.

    The most maintainable approach is to establish a small set of typed exceptions early, use them consistently across all endpoints, and document what each one means. Serverpod's exception system allows you to define serialisable exception classes that the client receives in a typed form — not just a string error message, but a structured object the Flutter app can handle specifically.

    // Define once, use consistently
    class ValidationException extends SerializableException {
      final String field;
      final String message;
    
      ValidationException({required this.field, required this.message});
    }
    
    class ResourceNotFoundException extends SerializableException {
      final String resourceType;
    
      ResourceNotFoundException({required this.resourceType});
    }
    

    With exceptions like these defined and used consistently, the Flutter client can handle errors in a way that is predictable and exhaustive. The developer writing the Flutter code knows exactly what can come back from a server call and can handle each case explicitly, rather than writing generic catch-all error handling and hoping for the best.

    The decision about how errors are represented is one of those early structural decisions that is almost invisible when the project is small and impossible to change cheaply when the project is large. Making it deliberately and early is significantly cheaper than inheriting an inconsistent system and normalising it across dozens of endpoints later.


    Handle Configuration as a First-Class Concern

    Serverpod projects have configuration that varies between environments — database connection strings, API keys for third-party services, server ports, feature flags. The way that configuration is handled is a structural decision that either makes environment management straightforward or creates a source of subtle errors that surfaces in production at the worst possible time.

    The default Serverpod configuration uses YAML files for database and server settings, with different files for development and production. That system works well for the configuration Serverpod itself needs. The additional configuration your application needs — the API keys, the feature flags, the service URLs that differ between environments — requires a consistent approach that you define.

    The approach that works best is to centralise all application configuration in a single class or module that reads from environment variables or configuration files at startup, validates that required values are present, and fails loudly if they're not. Spreading configuration reads across the codebase — an API key read directly in the service class that needs it, a feature flag read inline in the endpoint that checks it — produces a system where understanding what configuration your application requires means reading every file that touches external configuration.

    // Centralised — readable, validated, testable
    class AppConfig {
      final String stripeSecretKey;
      final String sendgridApiKey;
      final bool featureNewOnboarding;
    
      AppConfig._({
        required this.stripeSecretKey,
        required this.sendgridApiKey,
        required this.featureNewOnboarding,
      });
    
      static AppConfig fromEnvironment() {
        final stripeKey = Platform.environment['STRIPE_SECRET_KEY'];
        final sendgridKey = Platform.environment['SENDGRID_API_KEY'];
    
        if (stripeKey == null) throw Exception('STRIPE_SECRET_KEY is required');
        if (sendgridKey == null) throw Exception('SENDGRID_API_KEY is required');
    
        return AppConfig._(
          stripeSecretKey: stripeKey,
          sendgridApiKey: sendgridKey,
          featureNewOnboarding:
              Platform.environment['FEATURE_NEW_ONBOARDING'] == 'true',
        );
      }
    }
    

    Centralised configuration is readable — you can see everything the application depends on in one place. It fails fast — missing configuration is discovered at startup, not when the first request hits the code path that needs it. And it is testable — you can construct a specific configuration for tests without reaching into environment variables or reading files.


    The Structure Is the Conversation

    The organisation of a Serverpod project is, in a practical sense, a standing document about how the team has decided to think about the application. A domain-organised structure says: the team thinks in terms of domains, and new work belongs in the domain it affects. Thin endpoints and service layers say: the team has separated the concern of handling requests from the concern of doing work. Consistent error types say: the team has made a shared decision about how failure is communicated.

    These structural choices are also the first things a new team member reads when they join a project. Before they understand the business logic. Before they know the data model. They see the structure, and it either tells them clearly how the team works or leaves them to infer it from scattered signals.

    Structure is not glamorous work. It rarely shows up in sprint reviews. Nobody demos a well-organised directory tree. But it is the invisible infrastructure that determines whether adding a new feature feels like extending something coherent or patching something fragile.

    Serverpod gives you a capable framework. What you build inside it — the layers, the conventions, the organisation that turns a collection of endpoints and models into a maintainable system — is the part that's yours to get right.

    Getting it right from the beginning is significantly easier than recovering it later. That's the lesson most developers learn once. This article is the attempt to make sure you don't have to learn it the hard way.

    Try Dartform

    Skip the CLI. Build Serverpod backends visually.

    Free 1-week trial. Native macOS app. No App Store required.

    Download Dartform
    Dartform

    Ready to build Dart backends faster?

    Download Dartform and see the difference visual development makes. Start your 1-week free trial today.