Building Full Stack apps using Flutter and Dart Frog
A practical example on how to use only Dart to create robust apps
This is the first blog of a series of articles where we’ll talk about how to build Full Stack apps using only Dart. If you are a Flutter developer, you have certainly been using Dart for a long time. However, what happens when you have to integrate with a backend?
Normally, backends are made with a different programming language, yet nowadays, Dart has become more popular in the backend because there are lots of options to choose from. Flutter devs who are already using Dart are going to benefit greatly from writing their backends in Dart. So today we will focus on Dart Frog.
What is Dart Frog?
Well, as shown in their docs, Dart frog is a “ fast minimalistic backend framework for Dart”. It’s basically a higher level abstraction on top of shelf, written by the open-source team at Very Good Ventures. If you want to know more about Dart Frog, you can check out their docs or visit this recent podcast episode where we talk about it!
Building our Full Stack App
First, let’s discuss what we are building today. The purpose will be to build an app in Flutter that can retrieve a list of users from an API. With this simple example, we will be able to:
- Create our own Rest API using Dart Frog.
- Create a Flutter app and connect it with the API using an HTTP client.
- Discuss the advantages of having Full Stack apps using only Dart.
At the end of the article, we will have something like this:
Before we jump into the example, we have to say there are a couple of disclaimers:
- There are different technologies on how to build your API being the most popular ones: Rest API, GraphQL, web sockets and gRPC. This will ultimately depend on the problem we want to solve, but in this example we are going to go for a Rest API. Hopefully we will continue this series with other types of backend technologies.
- We’ll be building a minimal example of a Full Stack app, so we might not use all the best practices that are suggested.
Building our Rest API with Dart Frog
First, we need to install Dart Frog and create our server folder. We are going to have a folder structure mainly with two different folders, one for the backend called server and the other for the frontend called client.
├── client
├── server
└── README.md
In our server folder, run the command:
dart_frog create --project-name users_api
Now, the structure of a Dart Frog project is very bare-bones. If we are familiar with dart packages we can add any dependency that we want such as json_serializable
, and so on.
If we go to the routes folder, this is where all the magic will happen. We want to define our endpoints for having basic CRUD operations on our users collection. So, there is a little boilerplate needed to create these endpoints, I recommend using this brick that facilitates that process. If you don’t know what bricks and mason are, I recommend reading this article called “Enhance your Flutter development with mason” where we talk about it.
Let’s enter to our routes and create two nested folder called api
and v1
. This will let us have all of our endpoints under api/v1
folder. Then run the mason make command with our brick:
cd routes/api/v1 && mason make rest_frog
Then we simply input users as the name of our collection of endpoints.
├── routes
│ ├── api
│ │ ├──v1
│ │ │ ├── users
│ │ │ │ ├── [id].dart
│ │ │ | └── index.dart
We can see now that we have 5 different endpoints.
If we follow the REST standards, we know we need to have the following notation:
On one hand, we have a file called index.dart
inside routes/api/v1/users
that contains both endpoints for getting all users and creating a new user.
On the other hand, we have a file called [id].dart
. This is the actual notation for endpoints that received a dynamic parameter, in this case an ID. Inside this file we can see all the endpoints that need the ID parameter such as: updating a user, deleting a user or simply getting a single user by the ID.
If we run the server, we will be able to hit those endpoints, so let’s try it!
// run the server locally
dart_frog dev
// get all users
curl --request GET \
--url http://localhost:8080/users
The beauty of creating the backend using Dart Frog is that we can now treat the project as if it were a regular pure Dart package. That’s why we are going to create our src folder
and inside that a models folder
, where we will create our user model
.
But first, let’s add some dependencies to our pubspec.yaml
:
dart pub add equatable json_serializable
dart pub add --dev build_runner
Then create a user.dart
file inside src/models
with the following content:
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
/// {@template user}
/// A User object
/// {@endtemplate}
@JsonSerializable()
class User extends Equatable {
/// {@macro user}
const User({
required this.id,
required this.name,
required this.email,
});
/// Deserializes the given `Map<String, dynamic>` into a [User].
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
/// User identifier
final String id;
/// User name
final String name;
/// User email
final String email;
/// Serializes the given [User] into a `Map<String, dynamic>`
Map<String, dynamic> toJson() => _$UserToJson(this);
/// Returns a copy of a [User] using the given values
User copyWith({
String? id,
String? name,
String? email,
}) =>
User(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
);
@override
List<Object?> get props => [id, name, email];
}
Now we can auto generate our user model with:
dart run build_runner build -d
For practicality, let’s also create a barrel file to import all the models called models.dart
and export our user model file
:
export 'user.dart';
Next we will need to create a data source. Whenever a route will be executed, it has to get the data from somewhere, right? That’s why we’ll introduce to our example a generic interface for a data source, so no matter if we have an in memory strategy or a database, routes will communicate using an interface.
exportimport 'package:users_api/src/data/models/models.dart';
/// {@template users_data_source}
/// Interface that defines all the methods for Users data source
/// {@endtemplate}
abstract class UsersDataSource {
/// Returns the list of all the [User]
Future<List<User>> getAllUsers();
/// Returns a [User] based on the provided [id]
///
/// Returns an empty [User] if the id doesn't exist
Future<User> getUserById({required String id});
}
And now for the implementation, we can use a simple in memory strategy. Notice that we added some hardcoded users for testing purposes.
import 'package:users_api/src/data/models/user.dart';
import 'package:users_api/src/data/users_data_source.dart';
/// {@template in_memory_users_data_source}
/// An in-memory implementation of the [UsersDataSource] interface.
/// {@endtemplate}
class InMemoryUsersDataSource extends UsersDataSource {
/// In memory [User] list
final List<User> users = const [
User(id: '1', name: 'Gianfranco', email: 'gianfranco@email.com'),
User(id: '2', name: 'Gianfranco2', email: 'gianfranco@email.com'),
User(id: '3', name: 'Gianfranco3', email: 'gianfranco@email.com'),
User(id: '4', name: 'Gianfranco4', email: 'gianfranco@email.com'),
User(id: '5', name: 'Gianfranco5', email: 'gianfranco@email.com'),
];
@override
Future<List<User>> getAllUsers() async {
return users;
}
@override
Future<User> getUserById({required String id}) async {
return users.firstWhere(
(user) => user.id == id,
orElse: () => const User.empty(),
);
}
}
The last step required is to inject the data source using a middleware. Middleware is no other than a function that is executed before or after a request is processed. That’s why it seems to be the perfect place to put our data source, so we have to make sure every route will have access to it. There are other use cases such as handling user authentication, yet we will keep it simple for this example.
import 'package:dart_frog/dart_frog.dart';
import 'package:users_api/src/data/in_memory_users_data_source.dart';
import 'package:users_api/src/data/users_data_source.dart';
final _usersDataSource = InMemoryUsersDataSource();
Handler middleware(Handler handler) {
return handler
.use(requestLogger())
.use(provider<UsersDataSource>((_) => _usersDataSource));
}
So now, after all the heavy implementation of models, data sources and middlewares we can go back to where we started and finally finish our routes methods. Let’s keep only the get all users and get user by ID routes as we are not going to cover create, update and delete a user.
If we go to our get route
, we have to call our data source in order to get the list of available users. Then we simply return the list of users. In this way, the route doesn't depend anymore on where the data is coming, resulting on a more testable route where we can mock this dependency work with a local implementation in development or a production database when it is deployed.
Future<Response> _onGetRequest(RequestContext context) async {
final userDataSource = context.read<UsersDataSource>();
final users = await userDataSource.getAllUsers();
return Response.json(
body: {'users': users},
);
}
In the same way, we have to modify the get by ID route, so it can call the in memory data source and try to retrieve the user.
Future<Response> _onGetRequest(RequestContext context, String id) async {
final userDataSource = context.read<UsersDataSource>();
final user = await userDataSource.getUserById(id: id);
return Response.json(
body: user,
);
}At this point we are pretty much finished with the Dart Frog implementation as every route is correctly setup. However, we know our Flutter app will need a client that knows how to interact with the provided routes so we are going to go a step further and create that HTTP client as part of our backend project. Having an HTTP client is something that would maybe be out of the box in future releases of Dart Frog — for this, check out the roadmap. This could potentially be written as part of our frontend packages as well. However, as we are going to make a repository package in the Flutter app, we decided to leave the client in the Dart Frog project.
First, let’s add a new dependency:
dart pub add http
Now let’s make our HTTP client that can hit the URLs we made:
dartimport 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
/// {@template api_client}
/// An http API client
/// {@endtemplate}
class ApiClient {
/// {@macro api_client}
ApiClient({
required String baseUrl,
Client? client,
}) : _baseUrl = baseUrl,
_client = client ?? Client();
final String _baseUrl;
final Client _client;
Future<Map<String, String>> _getRequestHeaders() async {
return <String, String>{
HttpHeaders.contentTypeHeader: ContentType.json.value,
HttpHeaders.acceptHeader: ContentType.json.value,
};
}
/// GET /api/v1/users
///
/// Get the list of users
Future<Map<String, dynamic>> getUsers() async {
final uri = Uri.parse('$_baseUrl/api/v1/users');
final headers = await _getRequestHeaders();
final response = await _client.get(
uri,
headers: headers,
);
if (response.statusCode != HttpStatus.ok) {
throw Error();
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data;
}
/// GET /api/v1/users/:id
///
/// Get a single user by id
Future<Map<String, dynamic>> getUserById({
required String id,
}) async {
final uri = Uri.parse('$_baseUrl/api/v1/users/$id');
final headers = await _getRequestHeaders();
final response = await _client.get(
uri,
headers: headers,
);
if (response.statusCode != HttpStatus.ok) {
throw Error();
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data;
}
}
For the last step, we simply have to export everything needed as part of our library so that our app in Flutter can actually use it.
We need to create a file inside lib
that defines the name of the library and export all the necessary files:
library api;
export 'src/client/api_client.dart';
export 'src/data/in_memory_users_data_source.dart';
export 'src/data/models/models.dart';
export 'src/data/users_data_source.dart';
Building the frontend with Flutter
Now that we have our small Rest API in place, we are going to connect it using Flutter.
First, let’s create our project in the client folder:
cd client && flutter create --org=com.example --project-name=app .
Let’s import the API into our Flutter project, so we can use it in our pubspec.yaml
file:
dependencies:
cupertino_icons: ^1.0.2
flutter:
sdk: flutter
// add the users api here
users_api:
path: ../server
We will also need to build a repository that uses the API Client and basically acts as a middleware. It will parse the Dart Frog responses as well as throwing errors, if any.For that, we will need to create a pure Dart package inside our Flutter app, so we can create a folder at the same level of lib and call it packages.
Then we can create our pure Dart package using:
cd packages && flutter create --template=package users_repository
Under our lib folder we only need to create a UserRepository
class with the following:
import 'package:users_api/api.dart';
abstract class UsersRepositoryException implements Exception {
/// {@macro users_repository_exception}
const UsersRepositoryException(this.error, this.stackTrace);
/// The underlying error that occurred.
final Object error;
/// The relevant stack trace.
final StackTrace stackTrace;
}
/// {@template get_users}
/// Thrown during the get users if a failure occurs.
/// {@endtemplate}
class GetUsersFailure extends UsersRepositoryException {
/// {@macro get_users}
const GetUsersFailure(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
class UsersRepository {
const UsersRepository({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
Future<List<User>> getUsers() async {
try {
final response = await _apiClient.getUsers();
final users = <User>[];
for (final user in response['users'] ?? []) {
users.add(User.fromJson(user));
}
return users;
} catch (error, stackTrace) {
throw GetUsersFailure(error, stackTrace);
}
}
}
Next, let’s inject our dependencies, our User Repository and API client into a RepositoryProvider
. In that way, we will be able to access to that instance from anywhere using the BuildContext
. We'll also create a bootstrap method, so we can initialize all the dependencies before launching the app.
bootstrap.dart:
import 'package:flutter/material.dart';
void bootstrap(Future<Widget> Function() builder) async {
WidgetsFlutterBinding.ensureInitialized();
runApp(await builder());
}
For our main feature, we are going to display a list of users. To create the feature using best practices, we are going to need a bloc folder and a view folder. There’s some boilerplate required here, so we are going to use another brick called feature_route_bloc that we can download from BrickHub.
mason add feature_route_bloc && mason make feature_route_bloc
Once we named the feature users
, mason will generate all the necessary folder structure with our BLoC and view folder and barrel files.
In our users_event.dart
file, we will only define an event for fetching all the users from the dart frog API:
part of 'users_bloc.dart';
abstract class UsersEvent extends Equatable {
const UsersEvent();
}
class UsersFetched extends UsersEvent {
@override
List<Object?> get props => [];
}
In our users_state.dart
we will add a property for the list of users, so we can get the following state:
part of 'users_bloc.dart';
enum UsersStatus {
initial,
loading,
loaded,
error,
}
class UsersState extends Equatable {
const UsersState({
required this.users,
required this.status,
});
const UsersState.initial()
: this(
users: const [],
status: UsersStatus.initial,
);
final List<User> users;
final UsersStatus status;
UsersState copyWith({
List<User>? users,
UsersStatus? status,
}) {
return UsersState(
users: users ?? this.users,
status: status ?? this.status,
);
}
@override
List<Object> get props => [users, status];
}
Finally, we can add to our BLoC our new event and its handler, resulting on the following:
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:users_api/api.dart';
import 'package:users_repository/users_repository.dart';
part 'users_event.dart';
part 'users_state.dart';
class UsersBloc extends Bloc<UsersEvent, UsersState> {
UsersBloc({required UsersRepository usersRepository})
: _usersRepository = usersRepository,
super(const UsersState.initial()) {
on<UsersFetched>(_onUsersFetched);
}
final UsersRepository _usersRepository;
Future<void> _onUsersFetched(
UsersFetched event, Emitter<UsersState> emit) async {
try {
emit(state.copyWith(status: UsersStatus.loading));
final users = await _usersRepository.getUsers();
await Future.delayed(const Duration(seconds: 2));
emit(
state.copyWith(
status: UsersStatus.loaded,
users: users,
),
);
} on UsersRepositoryException {
emit(state.copyWith(status: UsersStatus.error));
}
}
}
Then in our view we just need to create the list of users and connect it to our BLoC.
User View
class UsersView extends StatelessWidget {
const UsersView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Users'),
),
body: BlocBuilder<UsersBloc, UsersState>(
buildWhen: ((previous, current) => previous.status != current.status),
builder: (context, state) {
if (state.status == UsersStatus.loading) {
return const Center(
child: SizedBox(
height: 50.0,
width: 50.0,
child: CircularProgressIndicator(),
),
);
}
return const _UsersList();
},
),
);
}
}
User List View
class _UsersList extends StatelessWidget {
const _UsersList();
@override
Widget build(BuildContext context) {
final users = context.select((UsersBloc bloc) => bloc.state.users);
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
leading: const Icon(Icons.person),
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
}
}
Well, know everything should be up and running, let’s run both the backend and frontend to see everything in action.
To run Dart Frog locally and in Flutter in a simulator or any device we can use:
dart_frog dev flutter run
That’s it! We did it! Everything is up and running.
Final thoughts
After building our whole example, we can now say:
Sharing code
Building the backend with Dart can allow you to share a lot of code between the frontend and the backend. This can really improve efficiency between teams (if your team is divided in backend and frontend) because now every change in the backend will be spread to the frontend immediately.
Let’s say we change a property in the user model in the backend, the frontend will now update that change as it’s using the same models, and we won’t be having undesired parse errors when we run the frontend while calling the API.
Dev experience
Because we are using the same language, apart from reusing a lot of the code, libraries, standards e.g: linters, we can also be benefit from having the same seamless experience of the available tooling.
Using Dart could be a real benefit, since we don’t have to switch contexts to a different language with different notation and practices. By sharing the same practices around Dart, your team can focus on just delivering features, sharing as much as possible, and unifying the team.
Should we create backends with Flutter and Dart Frog?
Yes, if your team feels comfortable enough and has experience with the Dart programming language, Dart Frog offers a really low entrance barrier to start developing your backends in Dart.
Furthermore, your team will experience all the benefits mentioned above. I suggest to start with a small project and try to gain experience identifying the best practices as well as working with the same language and try to make the best out of it.
One drawback could be that using Dart for the backend is a very recent practice, and other languages/frameworks might have more libraries and resources available, yet Dart is gaining popularity and momentum, so it’s not risky at all.
If you like the article, tell us if you want to see more about backend technologies using Dart and combining it with Flutter!
Originally published at https://somniosoftware.com.