Thoughts about scalable code structure based on developer as user philosophy combined with iterative design using AI.
Cover - Imaginary Gantt chart depicting astronomical growth and burnout:) Generated in leonardo.ai by feelings of building project.
App Architecture Series
Part 1 — Intro Notes — Previous
Part 2 — Stage 1 and 2 — You are here:)
Part 3 — Stage 3,4,5 and Conclusion Next (in progress)
Introduction
In the first part of the series, we defined key terms and principles. If you didn’t checkout — check Part 1 — Intro Notes.
Let’s continue our journey!
Before get started — currently I’ve completed writing last part, but still need about a week to prepare it properly and publish. Thank you for your understanding.
Stage 1 — Prototyping.
Team: 1 (or at best 2) Developers
Main focus: develop fast and throw away every piece you don’t like as a ClientUser (or call it business).
Treat your repo as a workbench — you don’t know what will work, so expect multiple projects at the same time, even in different frameworks, different languages.
I suggest the following folder structure in that stage.
Abstract code structure
/
├── prototypes
│ ├── {prototype_name}
│ │ ├── idea.md // This file contains the initial ideas and AI-generated insights for the prototype.
├── prompts
│ ├── agents
│ │ ├── {name}_agent.md // Rules and guidelines for AI agents used in the project.
│ ├── ideas
│ │ ├── {idea}_gen.md // Only AI-generated answers - ideas and prompts.
│ │ ├── {idea}.md // Handwritten notes and domain knowledge. May include AI-generated answers too.
Example code structure
/
├── prototypes
│ ├── flutter/todo_with_amazing_animation
│ │ ├── idea.md
│ ├── typescript/yjs_prototype
│ │ ├── idea.md
│ ├── dart/frog_server
│ │ ├── idea.md
│ ├── just_cool_feature
│ │ ├── idea.md
├── prompts
│ ├── agents
│ │ ├── flutter_agent.md
│ │ ├── flutter_architector_agent.md
│ │ ├── rust_agent.md
│ ├── ideas
│ │ ├── charmed_animated_scroll_gen.md
│ │ ├── that_gradient_shader.md
If you don’t like the word prototype, use playground or experiments — this word is important because in later stages you will want to make sure that these pieces are playable, even for newcomers, but not movable, so your git commit will not explode xD
Every prototype should be as small as possible — it can be one to three files just to test and experience your idea.
I would advise not to set up strict lints for this stage to keep code as simple and small as possible, but in case you are testing them or using a new language — certainly go for it.
Stage 2 — Domain iterations.
Team: 1–2 Developers
Main focus: set up such an architecture that will support and help fast iterations in domains and do so.
The stage when you have at least one idea, and you are about to start development of the MVP (Minimum Viable Product).
And you iterate over and over domains to find the best (at this particular moment) experience for end users.
A note about linter rules
Strangely, just a couple of linter rules — and your path to create or eliminate your technical debt is set..
Choose your linter rules even before you look into the code, and choose wisely. These rules will affect not only you, but your AI-generated code, code quality, and technical debt as eventually, your codebase will grow, and if you decide to change just one rule, you may trigger a huge amount of linter warnings, errors, and team discussions.
UI Kit
This stage highly depends on the question — are you a completely lone developer, or a team with a designer, because the first thing to do is to create some, maybe not perfect, but usable ui_kit.
To make this, try to look into different techniques for organizing it. Personally, I prefer to use at least two steps from the Atomic Design methodology: atoms and molecules, but in my opinion, it highly depends on what you expect to achieve with the design, for what platforms the product is developing, etc. For one product, it can have only atoms and molecules. Another one — specific repeatable patterns for complex product cards, layout templates, pages templates, for another — Material, Human Guidelines, FluentUI approach (usually in that case, it is quite convenient to find official design in Figma or Sketch, etc.).
Treat it as a completely standalone library to not tie to any business requirements and focus on how it would be convenient to use, to develop, and maintain not only by you but by other developers and AI too.
Hopefully, to get it into the right words — that doesn’t mean it should be perfect, rather it should have well-documented READMEs around to explain the principles of how to create a new component of this library and make it useful.
From my experience, it is critically important how well you organize and define taxonomy, documentation, and convenience — this will affect any developer’s decision in the future, how fast you will be able to iterate (develop new, maintain, fix, and change old) for features, screens, etc.
UI Kit relations & workflow example
Then create the basic app code structure.
Abstract code structure
├── core // everything that is not API, data, state, and ui. May contain isolated business logic, but not tied to any specific domain.
│ ├── utils // Utility functions, some duplicate isolated logic that is more related to the framework than the app, such as serialization, etc.
│ ├── extensions // Extensions for types.
│ ├── hooks // Optional hooks for logic duplication reduction. Usually handy for small portions of logic that are used in build methods.
│ ├── l10n // Optional localization files.
│ ├── side_services // Optional third-party services like Firebase, Analytics, etc.
├── data_local_api // I use word API, as it is just shorter:) but it has the same meaning as services, sources). Prefer to treat it as your local backend server.
├── data_remote_api // (services, sources) Remote APIs, preferably generated from backend schemas (REST or GraphQL or any other).
├── data_repositories // (optional) Handles both local and remote APIs.
├── data_models
│ ├── api_dto // (optional) Data Transfer Objects for APIs. Preferably generated for remote APIs, can be useful for local APIs too.
│ ├── {domain}_models // (optional) Domain-specific models.
├── data_states // Global level states for runtime data (notifiers, controllers, blocs, commands, etc.). Its purpose mostly to keep runtime data prepared to use in UI. These states can be used in all domains.
├── di // Dependency injection setup (it may be provider, get_it, inherited_models, etc.). Will be available for all domains in the app. Here you can set up API, states + methods to initialize them and use in `ui_root`.
├── ui_root // Place here everything that is needed for the app to start: bootstrap methods, data_state and side_services initialization, error catching (Firebase, Sentry), Analytics, etc.
├── ui_kit // uses below two Atomic Design principles: atoms and molecules, but you can use any other principles, such as Material, Human Guidelines, FluentUI, etc.
│ ├── UI_KIT_README.md // Short Rules and main principles defining how and why you made certain choices in underlying structure and links to the underlying READMEs. The last part is important, because as I found out, AI tools like to use README for designing or development.
│ ├── atoms
│ │ ├── ATOM_README.md // Domain knowledge for atoms.
│ ├── molecules
│ │ ├── MOLECULE_README.md // Domain knowledge for molecules.
├── ui_{domain} // all ui domains
│ ├── {DOMAIN}_KNOWLEDGE.md // Domain knowledge for the specific UI domain.
│ ├── {flow} // one or more screens or views united into one flow. Every flow has one entry point screen (or at least one) and every screen has one or more features.
│ │ ├── {flow}_screen.dart // Entry point for the flow.
│ │ ├── {feature_name} // Domain and flow-specific features.
├── envs.dart // [Environment variables](https://codewithandrea.com/tips/dart-define-from-file-env-json/)
├── router.dart // Your preferred router setup with defined routes
├── {platform}_app.dart // Entry point for the platform-specific app.
├── common_imports.dart // Optional. Place here all imports that are used in the whole app. Also, it is handy to place here all exports from barrel files from each top folder.
And finally, create domains ui{domains}_ which will include the most critical app experience and iterate over them.
A note about UI Domains
Schematic example of what can be shared, and what — can’t inside domains.
Inside domain, you may work with different types of data layer, screen states, widget states (in Flutter it is called Ephemeral state), some business logic, specific to feature models which you never intend to share with other domains.
I suggest a small decision to manage relations between domains — reuse only widgets (flows, screens, features), do not share/reuse data. If you need to share something from domain local data layer — move it to global data layer, from which it will be available for all domains. For example, a local model may go to data_models, screen state maybe refactored and go to data_states.
A word about data_states — Flutter documentation calls it App State. In other words, you can define it as global-level states for runtime data (notifiers, controllers, blocs, commands, etc.). Its purpose is mostly to keep runtime data prepared to use in UI. These states can be used in all domains.
Now let’s move back to global project structure.
Relations between global layers
Option 1 — with common_imports
Basic app relationships diagram using common imports for any ui domains folder
Option 2 — without common_imports
Basic app relationships diagram without using common imports for any ui domains folder
Note. Barrel File.
I find it very convenient to create barrel files on each top folder by exporting underlying classes in the same manner as Dart does for package export https://dart.dev/tools/pub/create-packages. This makes future refactoring easier and gives the ability to not mess with git changes when some file moves later.
Also, this practice makes it easier to think of all global folders as packages / libraries which are independent and may change their content, internal structure, but will maintain consistency with their external API.
At the same time, currently I have no experience in how it affects the ability of deferred imports. https://dart.dev/language/libraries#lazily-loading-a-library I think at least you may expect that it will have no effect on this stage, but since it is a way to load libraries — it may become more effective with stage 4.
Second note. Domain Knowledge
UI Domain relations & workflow example
Optionally, I recommend trying to place in all data and ui layer folders a Domain Knowledge file. It should be simple enough to regenerate and iterate with AI — this may give you context to work with particular domain features, develop, or maintain.
Semantically, to make it easy to get this context and navigate, it would be great if you align it with the above folder’s name, for example, if the folder above is ui_guide, then below you may place a file UI_GUIDE_CONTEXT.md or UI_GUIDE_KNOWLEDGE.md. The reason is simple: when you use file search — you will be able to quickly find this file, when you communicate with AI tool — you will much faster find required context.
Now let’s take a note on semantics.
Visual representation of layers separation, its relations and building order
First, the whole data layer has a prefix data for two reasons:
- to keep it at the top of the lib folder, so we could easily access it
- to keep always in mind that it is a data layer only
Second, domains (or UI layer) have a prefix ui. This makes this layer
- on visual and physical distance from the data layer
- stack all ui related domains together.
While it may seem an over-engineering attempt, defining the organization of data and ui layers visually distinctive from each other, will affect how you develop your application, because you will see it differently — more from an end-user perspective.
Finally, let’s summarize all words into graphics:
Abstract code structure
/
├── pubspec.yaml // <- define workspace to use the whole space as monorepo https://dart.dev/tools/pub/workspaces
├── apps/
│ └── {platform}_app/ // <- platform can be replaced by your first target platform, for example mobile_app
│ ├── core/
│ │ ├── core.dart
│ │ ├── utils/
│ │ ├── extensions/
│ │ ├── hooks/
│ │ ├── l10n/
│ │ └── services/
│ ├── data_local_api/
│ ├── data_remote_api/
│ ├── data_repositories/
│ ├── data_models/
│ │ ├── api_dto/
│ │ └── {domain}_models/
│ ├── data_states/
│ ├── di/
│ ├── ui_root/
│ ├── ui_kit/
│ │ ├── ui_kit.dart // <- place ui kit initially as a folder of mobile_app to keep initial simplicity
│ │ ├── UI_KIT_README.md
│ │ ├── atoms/
│ │ │ └── ATOM_README.md
│ │ └── molecules/
│ │ └── MOLECULES_README.md
│ ├── ui_{domain}/
│ │ ├── {DOMAIN}_KNOWLEDGE.md
│ │ ├── {flow}/
│ │ │ └── {feature_name}/
│ ├── envs.dart
│ ├── router.dart
│ ├── common_imports.dart
│ └── {platform}_app.dart
└── prototypes/
└── {prototype_name}/
└── idea.md // This file contains the initial ideas and AI-generated insights for the prototype.
└── prompts/
├── agents/
│ └── {name}_agent.md
└── ideas/
├── {idea}_gen.md
└── {idea}.md
Example code structure
/
├── pubspec.yaml
├── apps/
│ └── mobile_app/
│ ├── core/
│ │ ├── core.dart
│ │ ├── utils/
│ │ ├── extensions/
│ │ ├── hooks/
│ │ ├── l10n/
│ │ └── services/
│ ├── data_local_api/
│ ├── data_remote_api/
│ ├── data_repositories/
│ ├── data_models/
│ │ ├── data_models.dart
│ │ ├── api_dto/
│ │ ├── payments_models/
│ │ ├── chat_models/
│ │ └── profile_models/
│ ├── data_states/
│ │ ├── data_states.dart
│ │ ├── app_settings_notifier.dart
│ │ └── user_profile_cubit.dart
│ ├── di/
│ │ ├── di.dart
│ │ ├── dependency_injector.dart
│ │ ├── global_initializer.dart
│ │ ├── global_state_initializer.dart
│ │ └── global_state_providers.dart
│ ├── ui_root/
│ │ ├── ui_root.dart
│ │ ├── app_scaffold.dart
│ │ └── bootstrap.dart
│ ├── ui_kit/
│ │ ├── ui_kit.dart
│ │ ├── UI_KIT_README.md
│ │ ├── atoms/
│ │ │ ├── atoms.dart
│ │ │ ├── ATOM_README.md
│ │ │ ├── ui_text_field.dart
│ │ │ ├── ui_icon.dart
│ │ │ └── ui_badge.dart
│ │ └── molecules/
│ │ ├── molecules.dart
│ │ ├── MOLECULES_README.md
│ │ ├── ui_search_field.dart
│ │ ├── ui_app_bar.dart
│ │ └── ui_animated_grid.dart
│ ├── ui_payments/
│ │ ├── ui_payments.dart
│ │ ├── PAYMENTS_KNOWLEDGE.md
│ │ ├── transactions_flow/
│ │ │ ├── transactions_screen.dart
│ │ │ ├── features/ // consider making this name adaptive; if a screen lacks features, it may not need them. If features are complex, naming them may be more efficient than grouping them.
│ │ │ │ ├── transactions_list.dart
│ │ │ │ └── edit_transaction_popup.dart
│ │ └── pay/
│ │ ├── pay_screen.dart
│ │ ├── qr_pay/
│ │ │ ├── *
│ │ │ └── qr_pay_feature.dart
│ │ ├── provider_pay/
│ │ │ ├── *
│ │ │ └── provider_pay.dart
│ │ └── crypto_provider_pay/
│ │ ├── *
│ │ └── crypto_provider_pay.dart
│ ├── ui_profile/
│ │ ├── ui_profile.dart
│ │ ├── PROFILE_KNOWLEDGE.md
│ │ ├── auth/
│ │ └── settings/
│ ├── envs.dart
│ ├── router.dart
│ └── mobile_app.dart
└── prototypes/
├── flutter/todo_with_amazing_animation/
│ └── idea.md
├── typescript_yjs_prototype/
│ └── idea.md
├── dart_frog_server/
│ └── idea.md
└── just_cool_feature/
└── idea.md
/prompts/
├── agents/
│ ├── flutter_agent.md
│ ├── flutter_architector_agent.md
│ └── rust_agent.md
└── ideas/
├── charmed_animated_scroll_gen.md
└── that_gradient_shader.md
Personally, I find it difficult to nest flows too deep, so there we have two choices:
- when the flow becomes too huge or feels logically complete — it seems more convenient to acknowledge it as a domain and move it on a global level as a domain.
- or break this flow into several flows, if it feels there is too much different functionality in one place.
I think the most important part of this stage is to accumulate enough Domain Knowledge before diving too deep into endless features.
Stage 3 — Feature iterations.
Team: 2 Developers
Main focus: test and try features for specific domains to make these domains logically complete.
While this part is quite small for changes — and may not contain any radical changes in structure, I think it’s quite important on this stage to follow two principles:
Keep refreshing, refining, and updating domain knowledge from testing and using the application.
Keep every small feature implementation separate from the screen where it is placed. Treat the screen as a layout where you place widgets, and it would be much easier to reuse any feature-related widgets, as they will behave as self-contained micro applications.
This ends the first three stages of thoughts about scalable architecture approach.
Thank you for your time, to continue, use a link below.
Please don’t forget to share your thoughts in comments:) it really helps the algorithms give this article to others to read, and it would be great support from you:)
Next -> Stage 3,4,5 and Conclusion (in progress)
App Architecture Series
Part 1 — Intro Notes — Previous
Part 2 — Stage 1 and 2 — You are here:)
Part 3 — Stage 3,4,5 and Conclusion Next (in progress)
Top comments (0)