June 6, 2026 · 10 min read
Core-Variant Architecture: A Practical Pattern for Multiple Apps
Move stable business semantics into Core, leave concrete product implementation to Variant, and keep engineering independence while preserving the same business outcome.
Background
Some mobile products run into a specific engineering problem: multiple apps have highly similar core business outcomes. For example, they all need file selection, task submission, status synchronization, result display, record management, entitlement verification, and file saving. But they cannot simply be the same project with a different name, icon, and bundle id.
If every new app starts as a new project and old code is copied over and modified, it may look fast in the short term. Over time, several problems appear:
- Business logic is scattered across pages, network actions, SDK managers, and global configuration. Every branch must fix the same bugs repeatedly.
- The code structure, resource paths, dependency chain, and runtime behavior of multiple apps become too similar, making it hard to explain their independence.
- Product identity can be mixed up, such as API appId, Firebase, entitlement/subscription products, legal links, and signing profiles not fully matching the current product.
My current preference is to first extract stable business semantics into a thin Core, then derive multiple Variants from it. Core reuses business contracts and business outcomes. Variant owns the concrete implementation path and product identity.
Architecture Name
I call this pattern Core-Variant Architecture.
Here, Core refers to the stable business core: contracts, models, workflows, and app controllers. It defines what the business needs, and with what semantics those capabilities should be used.
Variant refers to a concrete product variant: UI, routing, state management, networking, storage, resources, SDKs, entitlement/subscription configuration, platform configuration, and product identity. It decides how the current product implements those capabilities, and how it is packaged, verified, and maintained independently.
In one sentence:
Core-Variant Architecture reuses business capabilities and business outcomes, not concrete product implementation.

To stay close to common project structures, the rest of this post uses base to represent Core, and variant to represent a concrete variant.
Core Idea
The point of this architecture is not “adding more directories”. It is separating two questions:
- What does the business need?
- How does the current product implement it?
base only answers the first question. It defines stable business capabilities, models, and workflows, such as:
- The user can submit a processing task.
- The user can provide an input file.
- The user can query task status.
- The user can view and delete processing records.
- The user can load entitlement configuration and complete verification.
- The user can save processing results.
variant answers the second question. It decides which networking library, state management, routing, storage, resource loading, platform SDK, analytics SDK, visual components, and platform configuration are used.
A simplified directory may look like this:
lib/
core/
contracts/
models/
workflow/
app/
app_capabilities.dart
processing/
records/
entitlements/
apps/
current/
variants/
api/
storage/
files/
features/
core is the most stable part, variants is the most replaceable part, and app sits between them, organizing business capabilities into actions that pages can call.
What Belongs in Base
base should hold stable business semantics, not concrete implementation.
For example, a file processing capability can be defined as a contract:
abstract interface class ProcessingCapability {
Future<ProcessingSubmission> submitDocument(
DocumentProcessingRequest request,
);
}
final class DocumentProcessingRequest {
const DocumentProcessingRequest({
required this.localFilePath,
required this.instructions,
this.presetId,
});
final String localFilePath;
final String instructions;
final int? presetId;
}
This interface expresses “submit a document processing request”. It does not expose Dio, Chopper, http, multipart, SDK objects, widgets, routing objects, or raw backend JSON.
This matters. Once implementation details appear inside a contract, every future variant is forced to inherit that implementation.
base can usually contain:
contracts: stable capability interfaces.models: business semantic models.workflow: pure business flows, as long as they do not depend on UI, SDKs, networking libraries, or platform plugins.app controllers: a business action layer between pages and capabilities.AppCapabilities: the capability set for the current app.- Boundary tests: to prevent core or features from directly depending on concrete implementation again.
base should not contain:
- Network clients.
- SDK calls.
- Platform plugins.
- State management frameworks.
- Routing frameworks.
- Concrete product names, bundle ids, legal links, entitlement/subscription product ids.
- A specific variant’s resource loading strategy.
- Concrete implementation code shared only to reduce duplication.
The Role of AppCapabilities
AppCapabilities is a key composition point. It is not a replacement for a service locator. It is a stable assembly list of business capabilities.
Example:
final class AppCapabilities {
const AppCapabilities({
required this.processing,
required this.recordList,
required this.recordDelete,
required this.entitlementCatalog,
required this.entitlementVerification,
required this.filePicker,
required this.fileSave,
});
final ProcessingCapability processing;
final RecordListCapability recordList;
final RecordDeleteCapability recordDelete;
final EntitlementCatalogCapability entitlementCatalog;
final EntitlementVerificationCapability entitlementVerification;
final FilePickerContract filePicker;
final FileSaveCapability fileSave;
}
Pages should not know where these capability implementations come from. At most, a page calls an app controller:
final result = await ProcessingController.current().submitDocumentFromFile(
localFilePath: filePath,
instructions: instructions,
presetId: presetId,
);
The current product’s composition root is then responsible for assembling the capabilities:
AppCapabilities buildCurrentCapabilities() {
final api = ApiProcessingAdapter();
return AppCapabilities(
processing: api,
recordList: ApiRecordAdapter(),
recordDelete: ApiRecordAdapter(),
entitlementCatalog: ApiEntitlementAdapter(),
entitlementVerification: ApiEntitlementAdapter(),
filePicker: PluginFilePicker(),
fileSave: LocalFileSaveAdapter(),
);
}
A future variant can replace the adapters, while pages and core do not need to notice.
What Variant Should Do
The goal of a variant is not just changing the skin. A mature variant should have its own implementation choices along several real engineering axes.
Common axes include:
- DI and composition root.
- State management.
- Routing.
- Networking library and DTO parsing approach.
- Local storage.
- File selection, validation, and saving strategy.
- Resource format and loading strategy.
- Entitlement/subscription flow.
- analytics/event pipeline.
- CI, signing, and export pipeline.
Changing the display name, icon, primary color, and launch screen is product packaging. It should not count as an architecture difference.
The minimum goal for a variant is: it can construct a complete AppCapabilities, and it can independently explain its product identity and implementation path.
Business Consistency Does Not Mean Code Consistency
There is an easy point to confuse here: multiple variants can have consistent business outcomes, but their code implementations do not need to be consistent, and should not be forced to be consistent.
Business consistency should be defined by acceptance scenarios, such as:
- A new user can start the app and complete initialization.
- The user can select an input file.
- The user can submit a processing task.
- The user can see task progress.
- The user can view results and processing records.
- The user can delete processing records.
- The user can load entitlement configuration, complete verification, and refresh user state.
- The user can view the privacy policy, terms of service, and support entry.
These are business outcomes. Whether the internals use GetX or Riverpod, Dio or Chopper, SharedPreferences or Hive, should not affect business acceptance.
So the key is to use the same set of business scenarios to accept different variants, instead of forcing different variants to share the same implementation.
Progressive Migration Is Safer Than a Rewrite
When extracting base from an existing project, the safest approach is not to replace every technical stack immediately. It is to first wrap the old implementation behind variant adapters.
Old code may look like this:
final result = await LegacyAction.submitProcessingJob(
file: file,
instructions: instructions,
);
After migration, first make it:
final result = await processing.submitDocument(
DocumentProcessingRequest(
localFilePath: file.path,
instructions: instructions,
),
);
The old LegacyAction does not need to be deleted immediately. It can be hidden behind an adapter:
final class ApiProcessingAdapter implements ProcessingCapability {
@override
Future<ProcessingSubmission> submitDocument(
DocumentProcessingRequest request,
) async {
final response = await LegacyAction.submitProcessingJob(
file: File(request.localFilePath),
instructions: request.instructions,
presetId: request.presetId,
);
return ProcessingSubmission(taskId: response.taskId);
}
}
This lets base run first and makes business behavior easier to align. Later, the variant can decide whether to replace the networking library, DTOs, error handling, cache, or file transfer implementation.
Boundary Tests Are Important
Without test constraints, this architecture can easily degrade. When developers are in a hurry, they may import API actions back into pages, or bring Flutter, GetX, SDKs, or plugins into core.
I recommend keeping at least several kinds of architecture tests:
lib/coredoes not import Flutter, GetX, networking libraries, SDKs, plugins, features, or variants.- features and app controllers do not call legacy actions directly.
- non-composition-root code does not import concrete variants.
- pages do not directly use SDK result objects or API DTOs.
- the current variant can construct a complete
AppCapabilities.
The test can be simple and directly scan source files:
test('core imports stay clean', () {
final forbidden = [
'package:flutter/',
'package:get/',
'package:dio/',
'package:in_app_purchase/',
'package:example_app/variants',
];
for (final file in dartFiles('lib/core')) {
final text = file.readAsStringSync();
for (final pattern in forbidden) {
expect(text.contains(pattern), isFalse, reason: file.path);
}
}
});
This kind of test is not complex, but it prevents architecture boundaries from slowly failing under future requirements.
Common Mistakes
Making Base Too Thick
base usually becomes thick because people want to reuse code. For example, multiple variants all need to submit file processing tasks, so multipart, token refresh, and the Dio client are all placed in base.
In the short term, this reduces duplication. In the long term, every variant becomes tied to the same implementation.
base should reuse semantics, not concrete implementation.
Mistaking UI Differences for Implementation Differences
Changing icons, colors, or a few resources does not equal a new engineering implementation. Real variant differences should be visible in dependencies, directories, composition roots, resource strategies, capability adapters, tests, and CI.
Continuing to Orchestrate Business Logic in Pages
Pages should handle input, display, and local UI state. They should not assemble API payloads, parse backend maps, call SDKs, process entitlement credentials, write tokens, or judge backend status codes.
That logic should go into app controllers or variant adapters.
Mixing Product Identity
This is one of the highest-risk problems in multi-variant projects. If one product accidentally includes another product’s Firebase, entitlement configuration, legal URL, API appId, or profile, it can create confusion around review, billing, analytics, and production debugging.
Product identity should be documented, checked, and protected by CI gates.
How I Would Implement It
If starting from scratch, I would follow this sequence:
- Freeze the current project facts, and list existing business capabilities and direct dependency points.
- Extract core contracts and core models.
- Establish
AppCapabilitiesand app controllers. - Wrap the old implementation under
variants/currentor a similar directory. - Migrate page call sites, one vertical slice at a time.
- Add boundary tests.
- Write a base extraction summary.
- Branch and create a worktree from base for the variant.
- Write the variant plan and identity matrix first.
- Choose real difference axes and gradually implement capability adapters.
- Add variant docs, preflight checks, and parity tests.
- Run
flutter test,flutter analyze, andgit diff --check.
Each step should answer one question: is this change stabilizing business semantics, or implementing a specific product variant? If the answer is unclear, the boundary usually has not been thought through.
When to Stop
Not all logic should be abstracted immediately. In these situations, I would stop and confirm instead of guessing:
- The semantics of entitlement/subscription products are unclear.
- The meaning of backend APIs is unclear.
- token, signing, user migration, or entitlement verification callbacks involve production state.
- Legal links, privacy descriptions, or support entries do not have final values.
- bundle id, Team, profile, Firebase, or entitlements need to change.
- A capability only serves one product, and it is unclear whether it belongs in base.
The goal of architecture abstraction is to reduce long-term risk, not create new uncertainty.
Summary
The core value of this Core-Variant architecture is separating “stable business outcomes” from “concrete engineering implementation”.
The thinner base is, the freer variants are.
The more stable contracts are, the less pages are polluted by implementation details.
The clearer identity is, the easier products are to maintain and explain independently.
The earlier tests are established, the less likely the architecture is to regress.
It is not an architecture that pursues more layers, nor a process that creates differences for the sake of creating differences. It is closer to an engineering discipline: business semantics can be shared, but concrete implementation must have boundaries; business outcomes can be consistent, but product identity and implementation paths must be real, clear, and verifiable.