Documento de Referência Arquitetural¶
Introdução¶
Este documento descreve a arquitetura proposta para o desenvolvimento de um aplicativo móvel utilizando flutter.
O código está disponível em: http://git.magnasistemas.com.br/mith/labs/projeto-referencia-flutter.git
Este código implementa um aplicativo para retornar usuários de uma equipe, onde você pode consultar detalhes de um usuário, ou fazer um CRUD completo de tarefas para a equipe.
Arquitetura Geral¶
A arquitetura do aplicativo seguirá os princípios de separação de responsabilidades, modularidade e escalabilidade. Utilizaremos uma abordagem MVVM (Model-View-ViewModel) para organizar o código em camadas distintas.
Componentes Principais¶
- View: Responsável pela apresentação da interface do usuário.
- ViewModel: Responsável pela lógica de negócios e interação com os dados.
- Model: Representa os dados e a lógica de negócios.
Bibliotecas e Frameworks¶
- Material Design 3: Para uma interface do usuário moderna e consistente.
- Design Pattern Repository: Para separação clara entre a fonte de dados e a lógica de negócios.
- GetX: Para gerenciamento de estado e navegação entre páginas.
- SQLite: Para armazenamento local de dados.
- Dio: Para requisições REST.
Iniciando¶
- Para iniciar a estrutura inicial do projeto, após a instalação correta do flutter, basta executar
flutter install <nome_do_projeto>
, respeitando o padrão de nomenclatura em snake case. - Neste exemplo o comando
flutter install poc_flutter
foi executado. - A seguinte estrutura será gerada:
- 📁 poc_flutter
- 📁 android
- 📁 ios
- 📁 lib
- 📄 main.dart
- 📁 linux
- 📁 macos
- 📁 test
- 📁 web
- 📁 windows
- 📄 analysis_options.yaml
- 📄 devtools_options.yaml
- 📄 pubspec.lock
- 📄 pubspec.yaml
- O desenvolvimento acontece dentro da pasta
lib
, sendo o arquivo main a porta de entrada da aplicação. - O arquivo
pubspec.yaml
guarda a configuração do projeto, no que diz respeito a compilação, as suas dependencias e estruturas de testes. - As demais pastas tratam dos arquivos de configuração necessários a compilação para a plataforma específica, não necessitando interação exceto se haja necessidade de especialização.
A configuração¶
Estrutura básica de dependencias gerado pelo sdk flutter contendo:
- O nome do projeto, que será o nome final do arquivo compilado, uma configuração de publicação e sua versão atual.
- A definição da versão do SDK Flutter.
- A declaração das dependencias utilizadas pelo projeto final e as dependencias utilizadas apenas durante o desenvolvimento, como os testes.
name: poc_flutter
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
get: ^4.6.6
shared_preferences: ^2.2.3
shared_preferences_ios: ^2.1.1
shared_preferences_android: ^2.2.2
hive: ^2.2.3
path_provider: ^2.1.3
sqflite: ^2.3.3
dio: ^5.4.3+1
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
Porta de entrada¶
O método main com retorno void é a porta de entrada para qualquer aplicação dart, e no flutter não poderia ser diferente. A porta de entrada mais básica de uma aplicação flutter seria:
- O método ensureInitialized do WidgetsFlutterBinding é muito importante na nossa aplicação, e provavelmente da grande maioria delas, pois a função dele é chamar o código nativo (caso precise) antes de rodar o runApp
- O método runApp é mantido, pois ele é quem amarra a estrutura inicial da aplicação flutter.
- A estrutura visual básica MyApp() é um Widget, que representa aquilo que é visível na tela.
Primeiro Widget¶
MyApp é um Widget, do tipo StatelessWidget, que configura tudo o que precisaremos a respeito estilização. Visto que o flutter é organizado por meio de composição, MyApp tem um papel fundamental, pois é a raiz dos widgets.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
colorScheme: ColorScheme.fromSeed(
surface: Colors.blue,
primary: Colors.blueGrey,
seedColor: Colors.grey,
secondary: Colors.grey,
),
useMaterial3: true,
),
home: const GetxHomeScreen(),
);
}
}
MaterialApp
é substituído por GetMaterialApp
que é uma implementação do Getx
, responsável pela navegação e injeção de dependências em toda a aplicação.
A utilização desta dependencia facilita a navegação e injeção de dependências, entretanto é necessário configurar toda a aplicação desde o início para ativar. Em breve um exemplo da utilização do potencial do Getx
.
2. GetMaterialApp
assim como MaterialApp
recebe a configuração inicial do Material e pode ser feita de várias formas.
Uma forma otimizada de utilizar o material é com Temas
, objetos ThemData
que encapsulam o estilo e podem ser replicados e utilizados por toda a aplicação.
3. O home page recebe o Widget
que de fato representa a sua tela inicial, neste exemplo utilizamos GetxHomeScreen
com uma injeção de dependencia.
Pages, controllers e repositories¶
Dentro da pasta lib estarão todos os arquivos dart com a implementação do aplicativo. Portanto, é conveniente organizar os arquivos dart em diretórios que identifiquem os seus objetivos.
Uma estrutura otimizada contendo o GetxHomePage seria:
- 📁 lib
- 📁 core
- 📁 getx
- 📁 git_users
- 📁 model
- 📁 view
- 📁 pages
- 📄 getx_home_screen.dart <-----
- 📁 view_model
- 📄 main.dart
Essa estrutura, que se assemelha ao MVC, no flutter é conhecido como Model-View-ViewModel
.
O MVVM separa a lógica de apresentação (View) da lógica de negócios (Model) e da lógica de visualização (ViewModel).
- Para fíns de familiaridade e experiencia de desenvolvimento, separamos a camada de model em entities, repositories e services.
- Outra parte importante seria a organização de pastas por feature, onde cada pasta teria o nome da feature, com a estrutura MVVM
A Home Page¶
O widget GetxHomePage, do tipo StatefulWidget, vai oferecer as funcionalidades iniciais do aplicativo. Recebe o GitUsersViewModel como dependencia para orquestrar a execução das lógicas de negócio.
class GetxHomeScreen extends StatefulWidget {
const GetxHomeScreen({super.key});
@override
State<GetxHomeScreen> createState() => _GetxHomeScreenState();
}
class _GetxHomeScreenState extends State<GetxHomeScreen> {
///Aqui ocorre a primeira injeção de dependências, onde a gente chama a ViewModel, pra fazer o controle da Tela
final GitUsersViewModel gitUsersViewModel = Get.put(GitUsersViewModel());
@override
void initState() {
gitUsersViewModel.fetchListGitUsers();
super.initState();
}
@override
Widget build(BuildContext context) {
final apiResponse = gitUsersViewModel.apiResponse;
return SafeArea(
child: Scaffold(
appBar: AppBar(
actions: [
InkWell(
onTap: () {
Get.to(
() => const GitUserTasksPage(),
);
},
child: const Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.label, color: Colors.white,),
),
)
],
title: const Center(
child: Text(
'Time AlmaViva',
style: TextStyle(color: Colors.white),
),
),
),
body: Obx(
() {
switch (apiResponse.value.status) {
case ApiStatus.loading:
return const Center(
child: CircularProgressIndicator(),
);
case ApiStatus.loaded:
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
itemCount: apiResponse.value.data?.gitUsersList?.length,
itemBuilder: (BuildContext context, int index) {
//Text('${apiResponse.value.data?.gitUsersList?[index].login}')
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Card(
elevation: 2,
child: ListTile(
onTap: () {
Get.to(
GitUserDetailPage(
model: apiResponse.value.data!.gitUsersList![index],
),
);
},
leading: CircleAvatar(
backgroundImage: NetworkImage(
apiResponse.value.data!.gitUsersList![index].avatarUrl.toString(),
),
),
title: Text('${apiResponse.value.data?.gitUsersList?[index].id}'),
subtitle: Text('${apiResponse.value.data?.gitUsersList?[index].login}'),
trailing: const Icon(Icons.arrow_forward_ios),
),
),
);
},
);
case ApiStatus.error:
return const Center(
child: Text('Por favor, tente novamente'),
);
case ApiStatus.initial:
default:
return const Center(
child: Text('Estado Inicial'),
);
}
},
),
),
);
}
}
- Como um StatefulWidget, possui a classe interna que representa o estado, extendendo a classe State.
- É utilizada uma estrutura básica do MaterialDesign que é o Scaffold, que possui estruturas visuais amigáveis aos dispositivos modernos como o AppBar e o Body.
- Body é o maior espaço visível no dispositivo recebe também um Widget, de qualquer tipo.
- Por meio da dependencia
GetX
, utilizando a implementaçãoObx
, injetamos a dependencia que foi gerada. Dentro do escopo doObx
utilizamos o ApiResponse, que irá fazer o controle da tela. - Nesta pagina estão exibidos os usuários de uma equipe, trazendo o nome deles, e sua foto de perfil
- Para isso, a lógica de consultas e inserções estão centralizadas na classe
GitUsersViewModel
.
A View Model¶
- A classe GitUsersViewModel possui a dependencia da classe GitUsersRepository.
- A
responsabilidade
da View Model éguardar os objetos que serão utilizados pelo estado
eavisá-los quando houver mudança
. - A View Model só funcionará corretamente se aplicarmos o conceito de herança, herdando a classe GetxController.
- Para que todos os widgets que observam os atributos e métodos de GitUsersViewModel é necessário chamar o método
obs
ao final das variaveis.
class GitUsersViewModel extends GetxController{
///A apiResponse é quem vai ficar responsavel por lidar com as respostas da API / Banco de Dados como listado abaixo
final Rx<ApiResponse<GitUsersResponseModel>> apiResponse = ApiResponse<GitUsersResponseModel>.initial('Sem dados').obs;
///Aqui pegamos uma lista de usuários, ela vem do Repository
Future<void> fetchListGitUsers() async {
apiResponse.value = ApiResponse.loading('Carregando Data');
try{
GitUsersResponseModel gitUsersList = await GitUsersRepository().fetchListGitUsers();
print(gitUsersList);
apiResponse.value = ApiResponse.loaded(gitUsersList);
} catch (e){
apiResponse.value = ApiResponse.error(e.toString());
print(e);
}
}
}
- Nota-se que toda a Aplicação da View Model, tem como base a classe ApiResponse, ela é nossa base para fazer o controle da View
O Repository¶
- O MarcaRepository é uma classe vanilla, sem atributos especiais nem acumulo de estado.
- Ela é quem faz a comunicação entre a Service e a View Model.
class GitUsersRepository {
GitUsersServiceImpl gitUsersService = GitUsersServiceImpl();
Future<GitUsersResponseModel> fetchListGitUsers() async{
GitUsersResponseModel response = await gitUsersService.fetchListGitUsers();
return response;
}
}
A Service¶
- A GitUsersServiceImpl é quem fica responsável por fazer as requisições REST, através dá biblioteca DIO, chamamos a requisição que nos retornará uma entidade.
class GitUsersService{
final Dio dio = Dio();
Future<GitUsersResponseModel> fetchListGitUsers() async {
try{
final response = await dio.get('https://api.github.com/users');
print(response.data);
return GitUsersResponseModel.fromJson(response.data);
} on DioException catch (e){
if(e.error != null){
throw e.error!;
}
throw UnknownException();
}
}
}
- Como podemos ver nessa classe, tentamos fazer o get de https://api.github.com/users ⧉.
- Caso seja uma resposta positiva, chamamos a classe GitUsersResponseModel, passando a resposta da requisição, que no nosso caso é uma lista de usuários
- Caso seja uma resposta negativa, fazemos a tratativa de erro.
A Entidade¶
- A GitUsersResponseModel e a GitUsersModel é quem fica responsável por ser nossas entidades
Exemplo Entidade de Objeto¶
class GitUsersModel {
final int? id;
final String? login;
final String? avatarUrl;
final String? reposUrl;
GitUsersModel({
this.id,
this.login,
this.avatarUrl,
this.reposUrl,
});
factory GitUsersModel.fromJson(Map<String, dynamic> json) {
return GitUsersModel(
id: json['id'],
login: json['login'],
avatarUrl: json['avatar_url'],
reposUrl: json['repos_url'],
);
}
}
Exemplo Entidade de Lista¶
class GitUsersResponseModel {
final List<GitUsersModel>? gitUsersList;
GitUsersResponseModel({
required this.gitUsersList,
});
factory GitUsersResponseModel.fromJson(List<dynamic> json) {
return GitUsersResponseModel(
gitUsersList: json != null
? List<GitUsersModel>.from(
json.map(
(i) => GitUsersModel.fromJson(i),
),
)
: null,
);
}
}
Navegação entre páginas¶
No body da GetxHomeScreen temos a lista de usuários e para cada usuário o método onTap está declarado fornecendo o comportamento de navegação por meio do Getx.
- Com o GetX podemos fazer uma navegação entre páginas respeitando o padrão do
Material
de forma organizada e simplificada. Esta navegação, por exemplo,mantém o estado
, oferece comportamentos deretorno
ebreadcrumb
se habilitado. - Para completar a navegação é necessário fornecer o outro widget para onde se destina e suas dependencias necessárias.
- No exemplo é fornecido a nossa classe
Entidade
, que vai servir de modelo para a pagina de Detalhes.
A GitUserDetailPage¶
- GitUserDetailPage possui o atributo model, que é recebido na navegação da home page para a de detalhes
- Podemos perceber um padrão, começamos instanciando a View Model
- No metódo initState, chamamos a instância da View Model para receber uma lista de repositorios do usuário, passando alguns
Paramêtros
comoreposUrl
- Após isso, continuamos construindo nossa classe, e usamos o Obx quando formos mexer com os estados de uma Pagina
class GitUserDetailPage extends StatefulWidget {
final GitUsersModel model;
const GitUserDetailPage({super.key, required this.model});
@override
State<GitUserDetailPage> createState() => _GitUserDetailPageState();
}
class _GitUserDetailPageState extends State<GitUserDetailPage> {
final gitUserReposViewModel = Get.put(GitUserDetailReposViewModel());
@override
void initState() {
gitUserReposViewModel.fetchListGitUserRepos(
GitUserDatailReposParams(reposUrl: widget.model.reposUrl),
);
super.initState();
}
@override
Widget build(BuildContext context) {
final apiResponse = gitUserReposViewModel.apiResponse;
return SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
),
body: Column(
children: [
HeaderUserDetailWidget(
model: widget.model,
),
const Padding(
padding: EdgeInsets.only(bottom: 24),
child: Text(
'Trabalhos do Usuário',
style: TextStyle(fontSize: 18),
),
),
Obx(
() {
switch (apiResponse.value.status) {
case ApiStatus.loading:
return const Center(
child: CircularProgressIndicator(),
);
case ApiStatus.loaded:
return Flexible(
child: ListView.builder(
itemCount: apiResponse.value.data!.gitReposList!.length,
itemBuilder: (ctx, i) {
return Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 5),
child: Card(
color: Colors.white,
elevation: 2,
child: ListTile(
contentPadding: const EdgeInsets.only(left: 16, bottom: 4, top: 4),
title: Text(
apiResponse.value.data!.gitReposList![i].name!,
),
subtitle: Row(
children: [
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.remove_red_eye,
),
),
Text(
apiResponse.value.data!.gitReposList![i].watchersCount.toString(),
),
const SizedBox(
width: 20,
),
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.alt_route,
),
),
Text(
apiResponse.value.data!.gitReposList![i].watchersCount.toString(),
),
],
),
),
),
);
},
),
);
case ApiStatus.error:
return const Center(
child: Text('Por favor, tente novamente'),
);
case ApiStatus.initial:
default:
return const Center(
child: Text('Estado Inicial'),
);
}
},
),
],
),
),
);
}
}
A GitUserTasksPage¶
- Podemos perceber novamente um padrão, começamos instanciando a View Model, porém, essa tela não é uma tela que recebe requisição de API
- Diferente das outras Pages, essa faz uma consulta com o banco de dados, todas as informações são alteradas e observadas usando um banco e não a API
- No metódo initState, chamamos a instância da View Model para receber uma lista de Tasks do banco de dados
- Após isso, continuamos construindo nossa classe, e usamos o Obx quando formos mexer com os estados de uma Pagina
class GitUserTasksPage extends StatefulWidget {
const GitUserTasksPage({super.key});
@override
State<GitUserTasksPage> createState() => _GitUserTasksPageState();
}
class _GitUserTasksPageState extends State<GitUserTasksPage> {
final tasksViewModel = Get.put(GitUserTasksViewModel());
@override
void initState() {
tasksViewModel.fetchTasks();
super.initState();
}
@override
Widget build(BuildContext context) {
final apiResponse = tasksViewModel.apiResponse;
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: const Text('Tasks Equipe', style: TextStyle(color: Colors.white),),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (_) => TaskWidget(
onSubmit: (List<String> values) async {
await tasksViewModel.createTask(title: values.first, description: values.last);
Get.back();
},
),
);
},
),
body: Obx(() {
switch (apiResponse.value.status) {
case ApiStatus.loading:
return const Center(
child: CircularProgressIndicator(),
);
case ApiStatus.loaded:
return ListView.separated(
itemBuilder: (ctx, index) {
final createdDate = DateFormat('dd/MM/yyyy').format(DateTime.parse(apiResponse.value.data![index].createdAt!));
return ListTile(
onTap: () {
showDialog(
context: context,
builder: (context) => TaskWidget(
task: apiResponse.value.data?[index],
onSubmit: (List<String> values) async {
await tasksViewModel.updateTask(
title: values.first,
description: values.last,
id: apiResponse.value.data![index].id!,
);
Get.back();
},
),
);
},
title: Text(
'${apiResponse.value.data?[index].title}',
),
subtitle: Text(
createdDate,
),
trailing: InkWell(
onTap: () async {
await tasksViewModel.deleteTask(
id: apiResponse.value.data![index].id!,
);
tasksViewModel.fetchTasks();
},
child: const Icon(
Icons.delete,
color: Colors.red,
),
),
);
},
separatorBuilder: (context, index) => const SizedBox(
height: 10,
),
itemCount: apiResponse.value.data!.length,
);
case ApiStatus.error:
return const Center(
child: Text('Por favor, tente novamente'),
);
case ApiStatus.initial:
default:
return const Center(
child: Text('Estado Inicial'),
);
}
}),
),
);
}
}
O banco de dados¶
Essas classes singleton centraliza a complexidade de lidar com o banco de dados SqLite contendo:
- Uma classe contendo a inicialização do banco de dados que seria a classe base, e outra que trabalha com os scripts do banco
- Todo método que
bloqueia a thread é retornado como
Future<>e deve ser tratado corretamente com o await para se obter o
Resultadodo
Future`. - O Sqlite segue a sintaxe sql e oferece métodos amigáveis a sinxate dart para interagir com os dados.
Exemplo Classe Base¶
class GitUserReposTasksDbService {
Database? _database;
Future<Database> get database async {
if (_database != null) {
return _database!;
} else {
_database = await _initialize();
return _database!;
}
}
Future<String> get fullPath async {
const name = 'tasks_db';
final path = await getDatabasesPath();
return join(path, name);
}
Future<Database> _initialize() async {
final path = await fullPath;
var database = await openDatabase(
path,
version: 1,
onCreate: create,
singleInstance: true
);
return database;
}
Future<void> create(Database database, int version) async => await GitUserReposTasksDb().createTable(database);
}
Exemplo Classe dos Scripts¶
class GitUserReposTasksDb {
final tableName = 'tasks';
Future<void> createTable(Database database) async {
await database.execute("""CREATE TABLE IF NOT EXISTS $tableName (
"id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"created_at" INTEGER NOT NULL DEFAULT (cast(strftime('%s', 'now') as int)),
PRIMARY KEY("id" AUTOINCREMENT)
);""");
}
Future<int> create({String? title, String? description}) async {
final database = await GitUserReposTasksDbService().database;
return await database.rawInsert(
"""INSERT INTO $tableName (title,description,created_at) VALUES (?,?,?)""",
[title, description, DateTime.now().millisecondsSinceEpoch],
);
}
Future<List<GitTasksModel>> fetchAllTasks() async {
final database = await GitUserReposTasksDbService().database;
final gitUsersList = await database.rawQuery(
"""SELECT * from $tableName ORDER BY (created_at)""",
);
return gitUsersList.map((gitUser) => GitTasksModel.fromJson(gitUser)).toList();
}
Future<GitTasksModel> fetchById({required int id}) async {
final database = await GitUserReposTasksDbService().database;
final gitUser = await database.rawQuery(
"""SELECT * from $tableName WHERE id = ?""",
[id],
);
return GitTasksModel.fromJson(gitUser.first);
}
Future<int> update({required int id, String? title, String? description}) async {
final database = await GitUserReposTasksDbService().database;
return await database.update(
tableName,
{
if (title != null) 'title': title,
if (description != null) 'description': description,
},
where: 'id = ?',
conflictAlgorithm: ConflictAlgorithm.rollback,
whereArgs: [id],
);
}
Future<void> delete({required int id}) async {
final database = await GitUserReposTasksDbService().database;
await database.rawDelete(
"""DELETE FROM $tableName WHERE id = ?""",
[id],
);
}
Future<void> deleteAll() async {
final database = await GitUserReposTasksDbService().database;
await database.delete(
tableName,
);
}
}
Extra¶
Propriedades¶
Em uma aplicação mobile existe muita aplicabilidade para configurações editáveis
, ou seja, uma persistencia local.
O Hive
é uma implementação que abstrai a plataforma e oferece uma api para interagir com propriedades
.
Para oferecer este comportamento por toda a aplicação, independente do escopo, utilizamos o Getx
, que assim como o Provider, oferece uma forma de injeção de dependencia.
A injeção do getx oferece uma forma mais global de injeção de dependencia, bastando extender GetxController
.
- Para interagir com o Hive é necessário fornecer um diretório dentro do sistema operacional.
- A dependencia
path_provider
auxilia com o métodogetApplicationDocumentsDirectory()
a obter o diretório com as caracteristicas ideais para guardar as propriedads. - Neste exemplo o Hive é utilizado para guardar a configuração de
Material ThemeMode
, para fornecer a funcionalidade de temadark
elight
na aplicação. - O código abaixo demonstra como inicializar o hive, inputar uma propriedade e recuperar uma propriedade.
class ThemeController extends GetxController {
var isDark = false.obs;
Map<String, ThemeMode> themeModes = {
'light': ThemeMode.light,
'dark': ThemeMode.dark
};
late SharedPreferences prefs;
static ThemeController get to =>
Get.find();
loadThemeMode() async {
Directory dir = await getApplicationDocumentsDirectory();
var box = await Hive.openBox('preferencias', path: dir.path);
String themeText = box.get('theme') ?? 'light';
isDark.value = themeText == 'dark';
setMode(themeText);
}
Future setMode(String themeText) async {
ThemeMode themeMode = themeModes[themeText]!;
Get.changeThemeMode(themeMode);
var box = await Hive.openBox('preferencias');
await box.put('theme', themeText);
}
changeTheme() {
setMode(isDark.value ? 'light' : 'dark');
isDark.value = !isDark.value;
}
}
Conclusão¶
A arquitetura proposta combina as melhores práticas e tecnologias atuais para o desenvolvimento de aplicativos móveis. A utilização do Material Design 3 garante uma interface do usuário moderna e intuitiva, enquanto as demais tecnologias oferecem uma base sólida para o desenvolvimento de um aplicativo robusto e escalável.
Com esta arquitetura, esperamos alcançar um código bem organizado, fácil de manter e expandir, proporcionando uma excelente experiência para os usuários do aplicativo.