While building haskcore - an IDE for Haskell on Compose Desktop - I extracted rules that allowed me to create an architecture of 40 isolated modules, where each module knows only what it needs.
That's how FLIP - Feature-Layered Isolated Platform - was born.
The main problem I faced during development: horizontal dependencies make the system fragile. The solution - strict vertical hierarchy.
FLIP consists of the following isolated modules:
-
:common:core- common logic components. -
:common:presentation- common UI components. -
:service- UI-less domain module. Provides a service interface.
interface SeedService : AutoCloseable {
val seed: Flow<Seed>
suspend fun generateSeed(): Either<Throwable, Unit>
}
-
:feature:core— logic module of a UI-feature. Knows nothing about other features. Uses services from:service. Provides use cases.
class GenerateRemoteRandomSeed(private val seedService: SeedService) : UseCase.Action {
override suspend fun Raise<Throwable>.action() = seedService.generateSeed().bind()
}
:feature:presentation— UI module of a UI-feature. Knows only about its:feature:coreand uses its use cases. Provides the view. Knows only about its own:feature:core.:entrypoint— module where dependency configuration and composition of views from:feature:presentationinto a single Composable happens.:platform— module that initializes the Composable provided by:entrypointfor each platform.
Arrows show the direction of dependencies.
How to structure your system?
Decide which features your app should have, then determine whether each feature has a UI:
- If it does — it’s a feature.
- If it doesn’t — it’s a service.
- If one feature’s logic is needed by another — extract it into a separate service.
Important: both :service and :feature:core contain domain logic:
-
:service— core domain. -
:feature:core— presentation domain (use cases).
Wrong:
:service:foo:feature:foo:core:feature:foo:presentation
Right:
:service:foo:feature:bar:core:feature:bar:presentation
Navigation in FLIP is just another feature. It aggregates and routes views passed as lambda slots, keeping it independent from other features.
NavigationView(
splash = {
SplashView(applicationScope = applicationScope)
},
profile = {
ProfileView(applicationScope = applicationScope)
}
)
This approach lets you add new screens without touching existing features.
A complete example is available in the official GitHub repository.


Top comments (0)