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.
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 routehttps://address.com/todos
. It handles the GET and POST requests. -
routes/todos/[id].dart
- The rest api handlers for the routehttps://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.
- Install melos cli
dart pub global activate melos
- Create a project folder and initialize the melos workspace
mkdir fullstack_dart_todo
cd fullstack_dart_todo
mkdir packages
Add the following to the pubspec.yaml
file:
name: remote_todo
environment:
sdk: '>=2.18.0 <3.0.0'
Add the melos as a development dependency:
dart pub add melos --dev
Add the following to the melos.yaml
file:
name: remote_todo
packages:
- packages/*
scripts:
analyze:
exec: Dart analyze .
Now you can run the melos bs
for bootstrapping the mono-repo. Bootstrapping has two primary roles:
- Installing all package dependencies (internally using
pub get
). - 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
Now we are ready to create the backend project.
cd packages
dart_frog create backend_frog
cd backend_frog
Start the dev server:
dart_frog dev
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
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
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
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
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
inroutes/todos/index.dart
```dart
Future onRequest(RequestContext context) async {
return switch (context.request.method) {
HttpMethod.get => await _get(context),
HttpMethod.post => await _post(context),
_ => Response(statusCode: HttpStatus.notFound)
};
}
Future _get(RequestContext context) async {
final todos = await context.read().fetchAll();
final json = [for (var todo in todos) todo.asMap];
return Response.json(body: json);
}
Future _post(RequestContext context) async {
final json = await context.request.json() as Map;
final todo = await context.read().add(Todo.fromMap(json));
return Response.json(body: todo.asMap);
}
* Handlers for `/todos/[id]` in `routes/todos/[id].dart`
```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();
}
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(),
),
);
}
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>()
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
];
}
}
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())),
],
),
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();
},
);
}
}
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);
},
),
],
);
}
}
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();
});
}
}
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;
<span class="k">return</span> <span class="n">Todo</span><span class="o">.</span><span class="na">fromMap</span><span class="p">(</span><span class="n">jsonDecode</span><span class="p">(</span><span class="n">json</span><span class="p">));</span>
}
@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;
<span class="k">return</span> <span class="p">[</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">todo</span> <span class="k">in</span> <span class="n">jsonDecode</span><span class="p">(</span><span class="n">json</span><span class="p">))</span> <span class="n">Todo</span><span class="o">.</span><span class="na">fromMap</span><span class="p">(</span><span class="n">todo</span><span class="p">)];</span>
}
@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;
<span class="k">return</span> <span class="n">Todo</span><span class="o">.</span><span class="na">fromMap</span><span class="p">(</span><span class="n">jsonDecode</span><span class="p">(</span><span class="n">json</span><span class="p">));</span>
}
@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),
);
}
}
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)