Skip to content

Backend Architecture

Loose coupling, separation of concerns and layered architecture should not only be applied to frontend development. These principles can also be applied during backend development. For example, concepts such as route navigation, data access, data processing and data models can be separated and tested in isolation.

Project structure

Putting the backend in the same repository as the frontend allows developers to easily import data models from the backend. Within the backend directory, developers should consider separating the following elements into dedicated directories:

  • Middleware providers
  • Routes
  • Data access
  • Data models
  • Tests

While providers, routes, and tests, can live in the root backend project, consider putting data models and data access into their own dedicated package(s). Ideally, these layers should be able to exist independently from the rest of the app.

  • Directoryapi/
    • Directoryroutes/
      • Directoryapi/
        • Directoryv1/
          • Directorytodos/
            • …
        • _middleware.dart
    • Directorytest/
      • Directoryroutes/
        • Directoryapi/
          • Directoryv1/
            • Directorytodos/
              • …
    • Directorypackages/
      • Directorymodels/
        • Directorylib/
          • Directorysrc/
            • Directoryendpoint_models/
              • …
            • Directoryshared_models/
              • …
        • Directorytest/
          • Directorysrc/
            • Directoryendpoint_models/
              • …
            • Directoryshared_models/
              • …
      • Directorydata_source/
        • Directorylib/
          • Directorysrc/
            • …
        • Directorytest/
          • Directorysrc/
            • …

Models

A models package should clearly define the necessary data models that the backend will be sharing with the frontend. Defining endpoint models makes the data necessary to communicate between frontend and backend more explicit. It also creates a data structure that can communicate additional metadata about content received, such as the total count of items and pagination information.

final class GetTodosResponse {
const GetTodosResponse({
int count = 0,
int pageNumber = 0,
List<Todos> todos = const [],
})
final int count;
final int pageNumber;
final List<Todos> todos;
}

Data Access

A data source package should allow developers to fetch data from different sources. Similar to the data layer on the frontend, this package should abstract the work of fetching data and providing it to the API routes. This allows for easy development by mocking data in an in-memory source when necessary, or also creating different data sources for different environments.

The best way to achieve this is by making an abstract data source with the necessary CRUD methods, and implementing this data source as needed based on where the data is coming from.

abstract class TodosDataSource {
Future<Todo> create({
required String name,
});
Future<List<Todo>> getAll();
Future<Todo?> get(String id);
Future<Todo> update(String id, Todo todo);
Future<void> delete(String id);
}

Routes

Routes should follow common best practices for REST API design.

Endpoints Should Have Descriptive Paths

Endpoints should be named for the collection of objects that they provide access to. Use plural nouns to specify the collection, not the individual entity.

my_api/v1/todos

Nested paths then provide specific data about an individual object within the collection.

my_api/v1/todos/1

When setting up a collection of objects that is nested under another collection, the endpoint path should reflect the relationship.

my_api/v1/users/123/todos

Use Query Parameters to Filter Properties of GET results

Query parameters serve as the standard way of filtering the results of a GET request.

my_api/v1/todos?completed=false

Map the Request Body of POST, PUT, and PATCH Requests

On requests to the server to create or update items, endpoints should take a stringified JSON body and decode it into a map so that the appropriate entity in the data source is changed. Since this is a common process for all create/update requests, consider adding a utility to map the request body.

extension RequestBodyDecoder on Request {
Future<Map<String, dynamic>> map() async =>
Map<String, dynamic>.from(jsonDecode(await body()) as Map);
}

The request body can then be converted into the correct data model like in the endpoint code.

final body = CreateTodoRequest.fromJson(await context.request.map());

Use PATCH to Send Update Requests

For update requests, PATCH is more advisable than PUT because PATCH requests the server to update an existing entity, while PUT requests the entity to be replaced.

PATCH my_api/v1/todos/1

DELETE should only require an identifier

DELETE requests should require nothing more than an identifier of the object to be deleted. There is no need to send the entire object.

DELETE my_api/v1/todos/1 //Data source should only require the ID

Return Appropriate Status Codes

Routes should also return proper status codes to the frontend based on the results of their operations. When an error occurs, sending a useful status and response to the client makes it clear what happened and allows the client to handle errors more smoothly.

final todo = context.read<TodosDataSource>().get(id);
if (todo == null) {
return Response(statusCode: HttpStatus.notFound, body: 'No todo exists with the given $id');
}
late final requestBody;
try {
requestBody = CreateTodoRequest.fromJson(jsonDecode(await context.request.body() as Map));
} catch (e) {
return Response(statusCode: HttpStatus.badRequest, body: 'Invalid request: $e');
}
try {
await someServerSideProcess();
} catch (e) {
return Response(statusCode: HttpStatus.internalServerError, body: 'Server error: $e');
}