DEV Community

Jhin Lee
Jhin Lee

Posted on

Let's build a full-stack dart app

I've been working with Flutter for a while and love it. It's an excellent tool for building cross-platform applications. But what if you want to build a full-stack application? We will explore how to build a full-stack application using Flutter and Dart.

Screenshot

The complete example code: https://github.com/leehack/fullstack_dart_todo

What will we build?

The todo application with Flutter and Dart! We will use Flutter for the client and Dart Frog for the server. Flutter will display the UI, and Dart Frog will handle the business logic and data storage.

Why full-stack in Dart?

The most significant benefit of using Dart for full-stack development is that you can use the same language on both the client and server. It means that you don't have to learn two different languages, which makes it easier to get started with full-stack development. Of course, you can also share your code between the client and server, which is a huge benefit.

Project Structure

We will use the mono-repo structure for this project. We will create three packages: backend, frontend, and shared. shared package will contain all the code that is shared between the client and server. backend package will contain all the code for the server(Dart Frog) and frontend package will contain all the code for the client(Flutter).

Shared package (packages/todo)

  • todo_entity.dart - The Todo data model with the business logic.
  • todo_repository.dart - The repository interface. The backend will implement it for storing data. The frontend will implement it for communicating with the backend.

Backend package (packages/backend_frog)

  • routes/todos/index.dart - The rest api handlers for the route https://address.com/todos. It handles the GET and POST requests.
  • routes/todos/[id].dart - The rest api handlers for the route https://address.com/todos/[id]. It handles each todo item's GET/PUT/DELETE requests.
  • lib/data/in_memory_todo_repository.dart - The repository implementation of the in-memory database.
  • routes/todos/_middleware.dart - Dependency Injection of the in-memory database that handlers will use.

Frontend package (packages/frontend_flutter)

  • data/remote_todo_repository.dart - The repository implementation of the remote database (rest api client).
  • providers/todo_list_provider.dart - The riverpod provider handles business logic and data storage.
  • presentation folder - The UI code for widgets.
  • pages/todo_page.dart - The todo page displays the todo list and form.

Mono-repo setup

We will use melos to manage the mono-repo. Melos is a tool for managing Dart projects with multiple packages.

  1. Install melos cli
    dart pub global activate melos
Enter fullscreen mode Exit fullscreen mode
  1. Create a project folder and initialize the melos workspace
    mkdir fullstack_dart_todo
    cd fullstack_dart_todo
    mkdir packages
Enter fullscreen mode Exit fullscreen mode

Add the following to the pubspec.yaml file:

    name: remote_todo

    environment:
      sdk: '>=2.18.0 <3.0.0'
Enter fullscreen mode Exit fullscreen mode

Add the melos as a development dependency:

    dart pub add melos --dev
Enter fullscreen mode Exit fullscreen mode

Add the following to the melos.yaml file:

    name: remote_todo

    packages:
      - packages/*

    scripts:
      analyze:
        exec: Dart analyze .
Enter fullscreen mode Exit fullscreen mode

Now you can run the melos bs for bootstrapping the mono-repo. Bootstrapping has two primary roles:

  1. Installing all package dependencies (internally using pub get).
  2. Locally linking any packages together.

Every time creating a new package, you must run the melos bs command. If you clone the project from GitHub, you must run the melos bs command.

Create the backend package with the Dart Frog

We'll use the Dart Frog framework for the backend. Dart Frog is built on top of shelf and mason and is inspired by many tools, including remix.run, next.js, and express.js.

We need to install the dart_frog_cli first.

    dart pub global activate dart_frog_cli
Enter fullscreen mode Exit fullscreen mode

Now we are ready to create the backend project.

    cd packages
    dart_frog create backend_frog
    cd backend_frog
Enter fullscreen mode Exit fullscreen mode

Start the dev server:

    dart_frog dev
Enter fullscreen mode Exit fullscreen mode

By default, it opens the web server at http://localhost:8080. You can see the default page. Thanks to the hot reload, you can see the changes immediately.

Create the frontend package with the Flutter

We'll use Flutter for the frontend. Flutter is Google's UI toolkit for building beautiful, natively compiled mobile, web, and desktop applications from a single codebase.

Let's create the Flutter project under the packages folder.

    cd packages
    flutter create frontend_flutter
    cd frontend_flutter
Enter fullscreen mode Exit fullscreen mode

Create the pure Dart shared package

The shared package will contain all the code that is shared between the client and server. We will create the pure dart package since it has no UI code.

Let's create the library project under the packages folder.

    cd packages
    dart create -t package todo
    cd todo
Enter fullscreen mode Exit fullscreen mode

To make the backend and frontend projects use the shared package, we must add the dependency to each project's pubspec.yaml file (backend_frog, frontend_flutter).

    dependencies:
      todo: ^0.0.1
Enter fullscreen mode Exit fullscreen mode

Once we run the melos bs command, the shared package will be linked to the backend and frontend project.

So, now we have everything ready to start the coding!

Global Architecture

Architecture

The shared package has the Todo data model and the repository interface. The backend and frontend packages implement the repository interface. The backend implements it as the in-memory database, and the frontend implements it as the rest api client. The Todo entity is the data model with the business logic that will be referenced in both the backend and frontend packages.

Backend

The backend is a simple CRUD application that exposes REST APIs. It uses the in-memory database for storing data. The in-memory database is a simple List that stores the Todo data model. The backend has two routes: https://address.com/todos and https://address.com/todos/[id]. The first route handles the GET and POST requests. The second route handles each todo item's GET/PUT/DELETE requests.

Routes

Dart Frog uses directory-based routing. Each route is a directory with the *.dart file. index.dart represents the root route of the path. For example, the routes/todos/index.dart represents the https://address.com/todos route. The routes/todos/[id].dart represents the https://address.com/todos/[id] route.

  • Handlers for /todos in routes/todos/index.dart
  Future<Response> onRequest(RequestContext context) async {
    return switch (context.request.method) {
      HttpMethod.get => await _get(context),
      HttpMethod.post => await _post(context),
      _ => Response(statusCode: HttpStatus.notFound)
    };
  }

  Future<Response> _get(RequestContext context) async {
    final todos = await context.read<TodoRepository>().fetchAll();
    final json = [for (var todo in todos) todo.asMap];
    return Response.json(body: json);
  }

  Future<Response> _post(RequestContext context) async {
    final json = await context.request.json() as Map<String, dynamic>;
    final todo = await context.read<TodoRepository>().add(Todo.fromMap(json));
    return Response.json(body: todo.asMap);
  }
Enter fullscreen mode Exit fullscreen mode
  • Handlers for /todos/[id] in routes/todos/[id].dart
  Future<Response> onRequest(RequestContext context, String id) async {
    return switch (context.request.method) {
      HttpMethod.get => await _get(context, id),
      HttpMethod.put => await _put(context, id),
      HttpMethod.delete => await _delete(context, id),
      _ => Response(statusCode: HttpStatus.notFound)
    };
  }

  Future<Response> _get(RequestContext context, String id) async {
    final todo = await context.read<TodoRepository>().getById(id);

    return Response.json(body: todo.asMap);
  }

  Future<Response> _put(RequestContext context, String id) async {
    final json = await context.request.json() as Map<String, dynamic>;
    final todo = Todo.fromMap(json);
    await context.read<TodoRepository>().updateById(todo);
    return Response();
  }

  Future<Response> _delete(RequestContext context, String id) async {
    final json = await context.request.json() as Map<String, dynamic>;
    final id = json['id'] as String;
    await context.read<TodoRepository>().deleteById(id);
    return Response();
  }
Enter fullscreen mode Exit fullscreen mode

Each handler does the simple thing. Just call the repository methods and return the response. The repository is injected into the handler using the dependency injection with the middleware.

Dependency Injection with the middleware

Dart Frog supports the dependency injection natively. We can inject the repository into the handler using the middleware. The middlewares are also directory based. For example, routes/todos/_middleware.dart is the middleware for the routes/todos route. The _middleware.dart file is a particular file name that represents the middleware for the directory.

InMemoryTodoRepository? _todoData;

Handler middleware(Handler handler) {
  return handler.use(
    provider<TodoRepository>(
      (context) => _todoData ??= InMemoryTodoRepository(),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above injects the InMemoryTodoRepository into the provider so the handler can access the repository. Thanks to the middleware with the dependency injection, we can easily use it from the handler as below.

await context.read<TodoRepository>()
Enter fullscreen mode Exit fullscreen mode

Repository with in-memory database

I said it's a database, but it's just a simple List. It's not an actual database. It's just an example. The repository interface is in the shared package. The backend implements it as the in-memory database repository. The repository contains the List of Todo data model.

  class InMemoryTodoRepository implements TodoRepository {
    /// Local data
    List<Todo> data = [];

    /// Get List of [Todo]
    List<Todo> get todos => data;

    @override
    Future<Todo> getById(String id) async {
      return data.firstWhere((todo) => todo.id == id);
    }

    /// Add a new [todo] and return [Todo] with new id
    @override
    Future<Todo> add(Todo todo) async {
      final newTodo = todo.copyWith(id: const Uuid().v4());
      data = [...data, newTodo];
      return newTodo;
    }

    @override
    Future<void> deleteById(String id) async {
      data = [
        for (final t in data)
          if (t.id != id) t
      ];
    }

    @override
    Future<List<Todo>> fetchAll() async {
      return data;
    }

    @override
    Future<void> updateById(Todo todo) async {
      data = [
        for (final t in data)
          if (t.id == todo.id) todo else t
      ];
    }
  }
Enter fullscreen mode Exit fullscreen mode

As you can see, it has some CRUD methods for mutating the List<Todo> data.

Frontend

The Frontend app is written in Flutter. It uses the Riverpod for state management and dependency injection. Same as the backend architecture, we store the implemented repository in the provider so the todoListProvider can access the repository. The todoListProvider is the riverpod provider that handles the business logic of the todo list.

Presentation and pages

There are two folders for UI code: presentation and pages. The presentation folder contains the code for widgets. The pages folder contains the UI code for pages. The pages/todo_page.dart is the main page that displays the todo list and form using the TodoListView and TodoTextField widgets in the presentation folder. main.dart is the entry point of the Flutter application that displays the TodoPage.

In the body of the TodoPage, it renders a column with the TodoListView and TodoTextField widgets.

const Column(
  children: [
    Padding(
      padding: EdgeInsets.all(16.0),
      child: TodoTextField(),
    ),
    Expanded(child: SelectionArea(child: TodoListView())),
  ],
),
Enter fullscreen mode Exit fullscreen mode

The TodoTextFiled Widget is a simple text field that handles the input and submits the todo item. When the user submits the text field, it's calling todoListProvier's add method.

class _TodoTextFieldState extends ConsumerState<TodoTextField> {
  final controller = TextEditingController();
  final focusNode = FocusNode();

  @override
  Widget build(BuildContext context) {
    return TextField(
      decoration: const InputDecoration(
        hintText: 'Enter a new todo',
      ),
      focusNode: focusNode,
      controller: controller,
      onSubmitted: (value) async {
        await ref.read(todoListProvider.notifier).add(
              Todo(id: '', title: value),
            );
        controller.clear();
        focusNode.requestFocus();
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The TodoListView Widget is a simple list view that displays the todo list. It watches the todoListProvider and rebuilds the list view when the todo list is changed. When the user clicks the checkbox, it calls todoListProvier's toggle method.

class TodoListView extends ConsumerWidget {
  const TodoListView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return switch (ref.watch(todoListProvider)) {
      AsyncData(:final value) => ListView.builder(
          padding: const EdgeInsets.all(16),
          itemCount: value.length,
          itemBuilder: (context, index) => TodoItem(todo: value[index]),
        ),
      AsyncLoading() => const Center(child: CircularProgressIndicator()),
      _ => const Center(child: Text("Error")),
    };
  }
}

class TodoItem extends ConsumerWidget {
  const TodoItem({super.key, required this.todo});

  final Todo todo;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(todo.title),
        Checkbox(
          value: todo.isDone,
          onChanged: (value) {
            ref.read(todoListProvider.notifier).toggle(todo);
          },
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Business logic with the Riverpod

The todoListProvider is the riverpod's AsyncNotifier provider that handles the business logic of the todo list. It calls the RemoteTodoRepository's methods for communicating with the backend. When it's initialized, it calls the fetchAll method of the TodoRepository to fetch all the Todo items from the backend and cache them. The add and toggle methods call the add and updateById methods of the TodoRepository. It updates the backend todo list and renews the local cache with the latest todo items in the provider.

@riverpod
class TodoList extends _$TodoList {
  @override
  FutureOr<List<Todo>> build() {
    return ref.read(todoRepositoryProvider).fetchAll();
  }

  Future<void> add(Todo todo) async {
    state = await AsyncValue.guard(() async {
      await ref.read(todoRepositoryProvider).add(todo);
      return ref.read(todoRepositoryProvider).fetchAll();
    });
  }

  void toggle(Todo todo) async {
    state = await AsyncValue.guard(() async {
      await ref
          .read(todoRepositoryProvider)
          .updateById(todo.copyWith(isDone: !todo.isDone));
      return ref.read(todoRepositoryProvider).fetchAll();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Rest API Client

RemoteTodoRepository is the rest api client for communicating with the backend, which is implementing the TodoRepository interface. It uses the http package for making the HTTP request. It's calling the http package's get, post, put, and delete methods for communicating with the backend.

class RemoteTodoRepository implements TodoRepository {
  @override
  Future<Todo> add(Todo todo) async {
    final client = http.Client();
    final response = await client.post(
      Uri.parse("$baseURL/todos"),
      headers: {
        'Origin': 'http://localhost:8080',
        'Content-Type': 'application/json',
      },
      body: jsonEncode(todo.asMap),
    );
    final json = response.body;

    return Todo.fromMap(jsonDecode(json));
  }

  @override
  Future<void> deleteById(String id) async {
    final client = http.Client();
    await client.delete(
      Uri.parse("$baseURL/todos/$id"),
      headers: {
        'Origin': 'http://localhost:8080',
        'Content-Type': 'application/json',
      },
    );
  }

  @override
  Future<List<Todo>> fetchAll() async {
    final client = http.Client();
    final response = await client.get(
      Uri.parse("$baseURL/todos"),
      headers: {
        'Origin': 'http://localhost:8080',
        'Content-Type': 'application/json',
      },
    );
    final json = response.body;

    return [for (final todo in jsonDecode(json)) Todo.fromMap(todo)];
  }

  @override
  Future<Todo> getById(String id) async {
    final client = http.Client();
    final response = await client.get(
      Uri.parse("$baseURL/todos/$id"),
      headers: {
        'Origin': 'http://localhost:8080',
        'Content-Type': 'application/json',
      },
    );
    final json = response.body;

    return Todo.fromMap(jsonDecode(json));
  }

  @override
  Future<void> updateById(Todo todo) async {
    final client = http.Client();
    await client.put(
      Uri.parse("$baseURL/todos/${todo.id}"),
      headers: {
        'Origin': 'http://localhost:8080',
        'Content-Type': 'application/json',
      },
      body: jsonEncode(todo.asMap),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary

We've built a full-stack application with Flutter and Dart. We used Flutter for the client and Dart Frog for the server. Flutter displayed the UI, and Dart Frog handled the business logic and data storage. We used the mono-repo structure for the project. We created three packages: backend, frontend, and shared. shared package contained all the code that is shared between the client and server. backend package contained all the code for the server(Dart Frog) and frontend package contained all the code for the client(Flutter).

We would need to run performance benchmarks to use dart as the backend language before we decided to build a production application, but the development experience was great. I liked the basic tooling of the dart_frog. It's simple and easy to use, like Flutter. I also liked the dependency injection with the middleware. I could build the full-stack application with the Dart Frog without spending too much time learning the framework.

The complete code example is available at https://github.com/leehack/fullstack_dart_todo

Top comments (0)