The architecture and code-quality habits behind every Tabaga Team product, with the numbers from our own repositories.
A solo studio has no second team to catch a bad merge, no QA department, and no one else to explain a crash to a pharmacist at eight in the morning. So the way we build is mostly a set of habits that make surprises rare. This post lists them honestly, with the numbers from our repositories rather than adjectives, so that you can judge for yourself whether the habits hold.
- 12
- products with the same folder shape
- 14
- failure types in one sealed hierarchy
- 468
- tests in a single app, many of them RTL and contrast
- 65
- findings in an audit we wrote about our own product
One skeleton
Every app we ship has the same skeleton: a core/ folder for what the whole app shares, and one folder per feature with data, domain and presentation inside. Presentation is widgets and state, domain is entities, use cases and failure types, data is repositories and mappers. State is BLoC or Cubit in most products and Riverpod in two; dependency injection is get_it with injectable; navigation is GoRouter with role-based guards.
Two rules make the skeleton worth having. Features import core, and core never imports a feature. Presentation depends on domain, domain on data, and never the other way. A new product starts by copying the shape, not by deciding one.
The backend is a detail
The rule that matters most is the direction of the arrows. Nothing above the data layer imports a Firebase, Supabase or Appwrite type. When a product needs a backend, it needs an interface in core/services/abstractions, and the concrete adapter is chosen once, in the container.
That rule is why the auto-parts marketplace could leave Firebase for Appwrite by touching about 78 Dart files and 18 cloud functions instead of the whole app, and why one construction product runs on Appwrite Cloud, on a VPS in Algeria, or on a machine in the customer's office with the same code and a different endpoint URL.
Walls, not conventions
Conventions decay; constraints do not. The restaurant ordering platform is a monorepo with three internal packages: core, UI and admin. The customer app depends on the first two and not on the third, so management code cannot end up in the customer binary even by mistake. The boundary lives in pubspec.yaml, not in a code review.
We apply the same instinct elsewhere. The fashion platform ships three apps from one repository as flavours, sharing one backend, and moved every sensitive write behind server functions so a client can only ask, never decide. The equipment marketplace enforces its business rules in the database: listing lifetime, moderation before publishing, owners cannot feature their own listings, through triggers and check constraints. The student-jobs marketplace keeps OTP codes in a table the client has no permission to read at all.
“If a rule matters, make it a constraint somewhere the app cannot reach.
”
Errors you can hold
Repositories return Either<Failure, T>. A failure is a sealed type, fourteen kinds in our largest product: network, auth, validation, permission, not found, conflict and so on, each carrying enough to explain itself in Arabic, French and English. The bloc maps Right to a loaded state and Left to an error state, and widgets never see an exception.
Two consequences we like. Optimistic concurrency becomes a ConflictFailure the UI can handle with a real message instead of a crash. And the code paths for "the server said no" are written and tested on day one, because the type system will not let you forget them.
The database is a design document
The offline-first products treat the local database as the primary store: Drift on SQLite, SQLCipher for encryption, migrations in one versioned place. One schema is at version 51 after two months of daily use. The pharmacy manager pins its storage location with a test, because a renamed product once pointed a new build at an empty folder and the records looked deleted. The licensing backend has a schema-drift test that fails the build when the live database and the migrations disagree, after CREATE TABLE IF NOT EXISTS silently skipped a new column in production while every unit test passed against an in-memory repository.
Tests as memory
We do not chase coverage; we pin decisions. The equipment marketplace has 468 tests across about thirty files, and a good share of them check internationalisation, right-to-left layout and colour contrast rather than logic. The auto-parts marketplace ran about 398 unit tests plus integration tests against a real backend before anyone trusted a migration. The kindergarten app has a test that fails if a font is fetched at runtime instead of bundled, because the app is used where there is no Wi-Fi. The pharmacy manager has one test for brand identifiers and one for legacy database adoption.
Each of those tests exists because something once went wrong, or nearly did. That is the only coverage metric we track.
From commit to counter
Commits follow the conventional format, in English or French depending on who will read the changelog. The analyzer enforces the RTL rules: EdgeInsetsDirectional, AlignmentDirectional, start and end, never left and right. CI runs on every push and pull request. Release tags are refused unless they are on main. Windows installers are checksummed and signed with an Ed25519 key in CI, and an unsigned installer is not publishable. Release channels are set per licence, not per build, so we always know which build a customer was running when something broke.
For the store apps, the boring parts are automated: the Play data-safety declaration is generated by a script, the console steps that have no API are documented step by step, and release notes are written in three languages in the changelog itself.
The design system lives inside the app
Every product carries its own design tokens and an in-app component gallery, reachable at /gallery in debug builds. One of them lists about forty-five widgets. Brand marks are drawn as vector geometry in code, and every launcher icon, splash and store graphic is generated from that one source, so the icon in the store and the mark in the app can never drift apart.
Saying "not ready" out loud
We audit our own work and write the result down even when it is unflattering. The fashion platform went through a 92-phase hardening programme with twelve role-based audits before handover. The refrigeration marketplace has an AUDIT.md with 65 findings, three of them critical, and the verdict "not production-ready" in the first paragraph. Roadmaps use three-colour status tables, and "not built" is a colour.
Nothing here is clever. It is the same small set of habits applied to every product, so that each one is easier than the last.