Command Palette

Search for a command to run...

Blog

How we build

Published on
Reading time
6 min read
Figures
5 figures
Tabaga Team

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

lib/core/theme · router · di · services/abstractionsfeatures/orders/one folder per featuredata/repositories · dtos · mappersdomain/entities · use cases · failurespresentation/bloc · screens · widgetsauth/same three foldersbilling/same three foldersgallery/every widget, in debug buildsthe two rulesfeatures import corecore never imports a featurepresentation → domain → datanever the other waysame in twelve productsFLUTTER · BLoC OR CUBIT · GET_IT + INJECTABLE · GOROUTER
Fig. 1.The shape of every product: one folder per feature with the same three rooms inside, and a core the features never reach into sideways.

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

presentationwidgets · BLoC / Cubit · GoRouter guardsdomainentities · use cases · Failure typesdatarepositories · DTOs · mappersabstractionsAuthService · Db · Storage · Push · Smsarrows point inward only; nothing imports a Firebase type above the data layerFirebaseSupabaseAppwriteDrift / SQLiteswapped in get_it
Fig. 2.One shape for every product: presentation depends on domain, domain on abstractions, and the backend is an adapter chosen in the DI container.

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

quickyes_coremodels · repositoriesquickyes_uitheme · widgets · l10nquickyes_adminstaff screens · blocscustomer appPlay · App Storestaff appinternal APK · Windowsno edgeenforced where?pubspec.yaml, not a code reviewsame idea elsewherethree flavours, one reposerver-authoritative writesrules in the databaseif it matters, make it a constraint
Fig. 3.A boundary you cannot cross by accident: the customer app does not depend on the admin package, so management code never ships in the customer binary.

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

repositorytalks to the adapterEither<Failure, T>NetworkFailure · AuthFailureValidationFailure · ConflictFailure… sealed, 14 kinds in one productRight(T)Left(Failure)LoadedError(f)UIno try/catch in widgetsno stack traces to usersevery failure has a sentence in ar · fr · en
Fig. 4.Errors are values: a repository returns Either a typed Failure or a result, the bloc maps both to state, and the screen never sees an exception.

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

commitconventional messageanalyzerRTL rules · lintstestsunit · widget · integrationCIevery push and PRtag on mainrefused elsewheresigned buildEd25519 · SHA-256channelstable · beta per licencefix(saisie): vingt-trois champs…468 tests in one product, 398 in anotheran unsigned installer is not publishableplus a schema-drift test that fails the build when the live database and the migrations disagree
Fig. 5.From a commit to a customer's machine: every step is a gate, and the last three are what make a solo studio's releases trustworthy.

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.

Command Palette

Search for a command to run...