Notes on building Flutter apps with better folder organization, reusable components, and scalable development flow.
A Flutter project starts fast. One main.dart, a few screens, a couple of packages. Three months later the same project holds forty screens, and every change touches six files in five folders. I have lived through that, so I now set the structure before the first feature, and I keep it boring on purpose.
I organize by feature first and by layer second. Each feature gets its own folder: auth, home, profile, attendance. Inside each one I keep three layers. The presentation layer holds pages, widgets and state holders. The domain layer holds entities and repository contracts, plain Dart with no Flutter imports. The data layer holds the code that talks to Firebase or an HTTP API, plus the mappers that turn raw responses into entities. A shared folder at the top holds what two or more features use: the theme, the router, common widgets, and the network client.
The rule that keeps this honest is the direction of imports. Presentation depends on domain. Data depends on domain. Domain depends on nothing. When a widget needs to fetch a list, it asks a repository interface, and a concrete class in the data layer answers. A page never sees a Firestore document or a JSON map. That boundary pays off the first time a backend field changes name: I edit one mapper and every screen keeps working.
State management follows the same discipline. I keep one state holder per screen, and it exposes a small set of states: loading, loaded, empty and failed. Widgets read those states and draw them. Business rules, such as how to compute a streak or whether a check-in falls inside a valid time window, live in domain use cases, so I can test them with a plain Dart test and no widget tree. In Workfra, the attendance rules sit in that layer, and the screens only display the result.
Reusable widgets deserve their own rules. I extract a widget when I use it a second time, not before. A button, a card, a list tile with a status chip: each takes plain values and callbacks, reads colors and text styles from the theme, and holds no business logic. Spacing comes from a few named constants instead of numbers scattered through build methods. When the design changes, I edit the theme and the constants, and I do not hunt through screens.
Naming helps more than people expect. A file named attendance_repository.dart in the domain folder and attendance_repository_impl.dart in the data folder tells a new reader which side of the boundary they stand on. I put one public class per file, keep files short enough to read in one screen, and let the folder tree act as the table of contents.
Folder structure cannot replace a development flow. My flow for a new feature runs in the same order every time. I write the entity and the repository contract. I build a fake repository that returns hard-coded data. I build the screen against the fake, so I can judge layout and states without waiting for a backend. Then I write the real data layer and swap it in through dependency injection. This order lets me show a working screen early and catch design problems while they cost a few minutes to fix.
Errors get a single path. The data layer catches exceptions from the network or Firebase and converts them into a small set of failure types. The state holder maps each failure to a message the user can act on. No screen carries its own try and catch block for network calls, which keeps behavior consistent across the app.
This structure has a cost. A tiny feature needs more files than a quick prototype would. For a throwaway demo I skip the layers and write everything in one file. For anything I expect to maintain, such as Redova, I accept the extra files, because adding a feature later takes an afternoon instead of a week of untangling.
I treat the structure as a living agreement, not a monument. When a folder grows past a dozen files, I split it. When two features share logic, I move it into shared and write down why. When a layer rule bites me, I question the rule before I bend it. The goal stays the same: a codebase where I can find any line in seconds, change it with confidence, and hand it to another developer without a long tour.