Forge is a Kotlin Multiplatform framework designed to streamline the development of applications targeting Android, iOS, Desktop (JVM), and Web (JS/Wasm).
It is built around Clean Architecture and focuses on:
- Clear separation of concerns (Domain / Data / Presentation)
- Testability and modularity
- Platform‑agnostic abstractions (navigation, logging, networking, persistence, etc.)
- A clear separation between API contracts and implementation details for critical infrastructure such as networking and persistence
- APIs that are fully usable from
commonMain, with platform‑specific details handled inside Forge modules
- Clean Architecture
- Project Structure & Modules
- Architectural Blueprint
- Feature Examples
- Dependency Guidelines
Forge separates code into distinct layers—Domain, Data, and Presentation. This makes your business logic easy to test and allows you to share the vast majority of your code across platforms.
For critical infrastructure (Networking, DataStore, Navigation...), Forge radically decouples the contract from the execution.
- API Modules: Contain only interfaces and models. They have zero third-party dependencies. They are safe to use anywhere in your
commonMain. - Implementation Modules: Implement the API using specific libraries (e.g., Ktor, OkHttp). These are treated as interchangeable "details."
Your Domain and Data layers must depend only on API modules. They never know which library is performing the network request or saving the data.
Forge is modular. You can adopt the entire framework or cherry-pick specific libraries to fit your existing architecture.
| Module | Description |
|---|---|
outcome |
Primitives for success, error, and progress handling. |
usecase |
Base classes for business logic units of work. |
viewmodel |
Stateful abstractions for Unidirectional Data Flow (UDF). |
network |
HTTP Client abstractions (API) and implementations. |
datastore |
Key-Value storage abstractions (API) and implementations. |
navigation |
Event-based navigation dispatching. |
logger |
Logging facades and platform writers. |
event |
Utilities for domain and application-wide events. |
paging |
Utilities for paginated data loading. |
Forge encourages a specific layering strategy to maximize code sharing and testability.
- Role: The heart of the application. Contains business rules and logic.
- Components: Use Cases, Domain Models, Repository Interfaces.
- Dependencies: Only Forge API modules (e.g.,
network-api). No platform code.
- Role: The implementation of the domain's requirements.
- Components: Repositories, Mappers, Caching logic.
- Dependencies: Forge API modules. It relies on implementations injected via the constructor.
- Role: State management and UI coordination.
- Components: ViewModels, UI State classes, Navigation events.
- Dependencies: Domain layer and Forge primitives (
viewmodel,outcome).
- Role: The "Composition Root." It wires everything together.
- Components: Dependency Injection (DI) modules, Platform entry points (Activity, Main).
- Dependencies: This is the only place that imports Forge Implementation modules (e.g.,
network-ktor) to inject them into the Data layer.
Encapsulate business logic in a UseCase that returns an Outcome. This forces you to handle success and failure scenarios explicitly.
The FlowOutcomeUseCase requires three generic arguments to enforce type safety across your Clean Architecture layers.
FlowOutcomeUseCase<Params, S, E>
-
Params(Input Parameters)- What it is: The data required to execute the use case.
- Usage: These are passed as the argument to the
execute(params)function. - Best Practice: If you need multiple arguments, group them into a
data class(e.g.,LoginParams). If the use case requires no input, useUnit.
-
S(Success Type)- What it is: The data returned when the operation completes successfully.
- Usage: This becomes the
.dataproperty insideOutcome.Success<S>. - Best Practice: This should be a Domain Model, not a raw DTO or API response object.
-
E(Error Type)- What it is: The specific type representing a failure in this business logic.
- Usage: This becomes the
.errorproperty insideOutcome.Error<E>. - Best Practice: Avoid using generic
ThrowableorException. Instead, use a sealed interface or enum (e.g.,LoginError.InvalidCredentials) to force the UI to handle specific failure scenarios explicitly.
class UpdateProfileUseCase : FlowOutcomeUseCase<ProfileParams, UserProfile, ProfileError>() {
// 1. The 'Params' type is used here as the argument
override suspend fun FlowCollector<Outcome<UserProfile, ProfileError>>.execute(params: ProfileParams) {
if (params.name.isEmpty()) {
// 3. The 'Error' type is emitted here
emitFailure(ProfileError.NameEmpty)
return
}
val updatedProfile = api.update(params)
// 2. The 'Success' type is emitted here
emitSuccess(updatedProfile)
}
}Avoid try-catch blocks by using emitCatching. It executes the block, captures exceptions, and maps them to your specific domain error type.
class ReadFileUseCase(
private val fileManager: FileManager
) : FlowOutcomeUseCase<String, String, FileError>() {
override suspend fun FlowCollector<Outcome<String, FileError>>.execute(fileName: String) {
// Tries to read the file. If an IOException occurs, it maps to FileError.ReadFailed
emitCatching(
errorMapper = { throwable -> FileError.ReadFailed(throwable.message) },
block = { fileManager.readText(fileName) }
)
}
}When working with the Forge Network module, emitFrom automatically unpacks an ApiResponse, handling the Success/Error branching for you.
class FetchUserUseCase(
private val userRepository: UserRepository
) : FlowOutcomeUseCase<String, User, UserError>() {
override suspend fun FlowCollector<Outcome<User, UserError>>.execute(userId: String) {
val apiResponse = userRepository.fetchUser(userId)
// Automatically emits Success(User) OR Failure(UserError)
emitFrom(apiResponse) { apiError ->
// Map the network error (HTTP 404, 500, etc.) to a Domain Error
when (apiError.code) {
404 -> UserError.NotFound
else -> UserError.Unknown
}
}
}
}Since FlowOutcomeUseCase returns a Flow, it integrates naturally with ViewModelsand especialy when using Forges StateViewModel.
When you dont want to use the StateViewModelyou can consume the UseCase like this
// In your ViewModel
fun increaseCount(currentCount: Int) {
viewModelScope.launch {
increaseCounterUseCase(IncreaseCounterUseCase.Params(currentCount))
.collect { result ->
when (result) {
is Outcome.Progress -> updateState { it.copy(isLoading = true) }
is Outcome.Success -> updateState { it.copy(isLoading = false, message = "Done!") }
is Outcome.Error -> updateState { it.copy(isLoading = false, error = result.error) }
}
}
}
}with the StateViewModelyou can a few helper functions to consume UseCases
private val posts = getDummyPostsUseCase(Unit)
.onEachOutcome(
onProgress = { setState(ExampleState.Loading) },
onFailure = { sendUIEvent(UIEvent.Snackbar(it.message)) },
onSuccess = { posts ->
reduceStateOrCreate<ExampleState.Success>(
reducer = { copy(posts = posts) },
create = { ExampleState.Success(posts = posts) }
)
}
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)Directly consuming a UseCase
viewModelScope.launch {
decreaseCounterUseCase.invoke(state.count)
.collectOutcome(
onProgress = { setState(DetailState.Loading) },
onSuccess = { setState(DetailState.Details(it)) },
onFailure = { sendUIEvent(UIEvent.Snackbar(it.message)) }
)
}Forge comes with two alternative types of UseCase. UseCase for one-shot suspendable operations and FlowUseCase for reactive streams.
The UseCase<Params, Output, Error> class provides a structured way to execute synchronous or asynchronous logic while enforcing typed error handling.
It returns a ResultOutcome<Output, Error> rather than throwing exceptions, promoting safer, predictable control flow.
class LoginUser(
private val repo: UserRepository
) : UseCase<LoginParams, User, LoginError>() {
override suspend fun UseCaseScope<LoginError>.execute(params: LoginParams): User {
// 1. Validation (Short-circuits if false)
ensure(params.email.isNotEmpty()) { LoginError.EmptyEmail }
// 2. Safe execution (Catches exceptions)
val rawUser = catch(
block = { repo.fetchUser(params.email) },
mapper = { LoginError.NetworkError }
)
if (rawUser.password != params.password) {
raise(LoginError.WrongPassword)
}
return rawUser
}
// Handle unexpected crashes (e.g., OOM, weird parsing errors)
override fun mapError(t: Throwable): LoginError = LoginError.Unknown(t)
}The FlowUseCase<Params, Output> is a lightweight wrapper for operations that return a stream of data.
class ObserveUserBalance(
private val repo: WalletRepository
) : FlowUseCase<String, Double>() {
override fun execute(params: String): Flow<Double> {
return repo.getBalanceFlow(userId = params)
.map { it.amount }
}
}The StateViewModel is the core of the Presentation layer. It enforces a strict Unidirectional Data Flow by ensuring that the View only observes State and sends Intents. It also includes built-in support for state caching (restoration), navigation, and one-off events.
Key Features:
- Type-Safe Reducers: Update state only if the current state matches a specific type (e.g., only update a counter if the state is
Loaded). - State Machine Logic: Process specific Intents only when in specific States via
handleIntent. - State Caching: Automatically caches previous state types to allow easy restoration (e.g., returning from a detail screen).
- Navigation & Events: Built-in dispatchers for routing and one-off UI events (Snackbars, Toasts).
- Navigation Results: Callback for Type-Safe navigation results triggered by other screens (NavBackStackEntry).
You normaly would want to create your own abstract BaseViewModel because the StateViewModel requires some dependencies that are easier to manage when having a BaseViewModel.
All examples will use exactly these BaseViewmodel:
abstract class BaseViewModel<State : Any, Intent> : StateViewModel<State, Intent, UIEvent> {
constructor(initialState: State, eventBus: EventBus<UIEvent>?) : super(
initialState = initialState,
eventBus = eventBus,
navigationDispatcher = resolveNavigationDispatcher(),
)
constructor(initialState: State, useEventBus: Boolean = true) : this(
initialState = initialState,
eventBus = if (useEventBus) resolveEventBus() else null,
)
private companion object Companion : KoinComponent {
fun resolveEventBus(): EventBus<UIEvent> = get()
fun resolveNavigationDispatcher(): NavigationDispatcher = get()
}
}abstract class BaseViewModelWithNavResult<State : Any, Intent, NavResult : Any> :
NavResultAwareStateViewModel<State, Intent, UIEvent, NavResult> {
constructor(initialState: State, eventBus: EventBus<UIEvent>?) : super(
initialState = initialState,
eventBus = eventBus,
navigationDispatcher = resolveNavigationDispatcher(),
)
constructor(initialState: State, useEventBus: Boolean = true) : this(
initialState = initialState,
eventBus = if (useEventBus) resolveEventBus() else null,
)
private companion object Companion : KoinComponent {
fun resolveEventBus(): EventBus<UIEvent> = get()
fun resolveNavigationDispatcher(): NavigationDispatcher = get()
}
}// 1. Define the Contract
sealed interface DetailState {
data object Loading : DetailState
// Scoped Intents: These actions are only valid when the screen is in 'Details' state
data class Details(val count: Int) : DetailState {
sealed interface Intent : DetailState.Intent {
data object IncreaseCounter : Intent
data object DecreaseCounter : Intent
data object NavigateBack : Intent
}
}
// Base Intent Interface
interface Intent
}This example demonstrates observing a use case, handling intent-state matching, and managing navigation results.
class DetailViewModel(
count: Int,
observeCounterUseCase: ObserveCounterUseCase,
private val increaseCounterUseCase: IncreaseCounterUseCase,
private val decreaseCounterUseCase: DecreaseCounterUseCase
) : BaseViewModel<DetailState, DetailState.Intent>(initialState = DetailState.Details(count = count)) {
// 1. Reactive State Management
// We listen to a UseCase Flow and update the UI state automatically.
private val currentCount = observeCounterUseCase(Unit)
.onEach {
reduceStateOrCreate<DetailState.Details>(
reducer = { copy(count = it) },
create = { DetailState.Details(it) }
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)
// 2. The Entry Point (State Machine)
override fun executeIntent(intent: DetailState.Intent) = when (intent) {
// use handleIntent to ensure we are in the correct state before processing
is DetailState.Details.Intent.DecreaseCounter -> handleIntent<_, _>(
intent = intent,
handler = ::handleDecreaseCounter
)
is DetailState.Details.Intent.IncreaseCounter -> handleIntent<_, _>(
intent = intent,
handler = ::handleIncreaseCounter
)
is DetailState.Details.Intent.NavigateBack -> handleIntent<_, _>(
intent = intent,
handler = ::handleNavigateUp
)
// If we receive an Intent not valid for the current state, handleIntent
// automatically calls handleInvalidState().
// This functions throws per default but should get overridden in production
}
// 3. Intent Handlers
private fun handleDecreaseCounter(
state: DetailState.Details,
intent: DetailState.Details.Intent.DecreaseCounter
) {
// 'collectOutcome' is a Forge helper to handle Success/Error/Progress easily
viewModelScope.launch {
decreaseCounterUseCase.invoke(DecreaseCounterUseCase.DecreaseCounterParams(state.count))
.collectOutcome(onProgress = { setState(DetailState.Loading) })
}
}
private fun handleIncreaseCounter(
state: DetailState.Details,
intent: DetailState.Details.Intent.IncreaseCounter
) {
viewModelScope.launch {
increaseCounterUseCase.invoke(IncreaseCounterUseCase.IncreaseCounterParams(state.count))
.collectOutcome(onProgress = { setState(DetailState.Loading) })
}
}
// 4. Navigation with Results
private fun handleNavigateUp(
state: DetailState.Details,
intent: DetailState.Details.Intent.NavigateBack
) = dispatchNavigationEvent(
event = NavigationEvent.NavigateUpWithResult(
key = NavigationResult.ExampleNavResult, // Type-safe key
value = state.count
)
)
}Forge decouples navigation logic from the UI. ViewModels simply "request" navigation via events, and the UI layer (Compose, SwiftUI, Fragment) observes and executes them.
Crucially, Forge provides a standardized way to pass data back from a screen (like startActivityForResult or setFragmentResult), which works seamlessly across platforms.
This API does not depend on any navigation library. Forge provides a implementation of the API using the Nav2 androidx libraries fork by jetbrains that supports all targeted platforms.
I might add alternatives later. You can ofc implement the API by yourself using any other library.
First, define a central registry of keys. By inheriting from NavResultKey<T>, you enforce that a specific key always carries a specific type of data (e.g., Int), preventing runtime casting errors.
// Global file: NavigationResult.kt
sealed class NavigationResult<T>(
key: String,
) : NavResultKey<T>(key) {
// Defined globally, reusable across modules if needed
data object ExampleNavResult : NavigationResult<Int>("example_nav_result")
}Create a sealed interface representing the possible results a flow can return. Primitives and kotlinx serialziables are supported
sealed interface MainNavigationResult {
data class CounterUpdated(val count: Int) : MainNavigationResult
}As seen in the DetailViewModel example, you dispatch events using dispatchNavigationEvent.
// In DetailViewModel
fun saveAndExit(count: Int) {
dispatchNavigationEvent(
event = NavigationEvent.NavigateUpWithResult(
key = NavigationResult.ExampleNavResult, // Type-safe key
value = state.count
)
}The receiving ViewModel inherits from NavResultAwareStateViewModel. You specify the expected result type as a generic argument (MainNavigationResult in this case).
The onNavResultReceived function is the entry point. Using reduceStateOrCreate is highly recommended here: it handles cases where the parent screen might be in a Loading or Empty state when the result arrives, ensuring the state is correctly initialized or updated.
class MainViewModel(
getDummyPostsUseCase: GetDummyPostsUseCase
) : BaseViewModelWithNavResult<ExampleState, ExampleState.Intent, MainNavigationResult>(
initialState = ExampleState.Loading,
) {
override fun onNavResultReceived(event: MainNavigationResult) = when (event) {
is MainNavigationResult.ExampleEvent -> reduceStateOrCreate<ExampleState.Success>(
reducer = { copy(count = event.count) },
create = { ExampleState.Success(count = event.count) }
)
}
}Finally, you need to bridge the platform's navigation system with your ViewModel. Forge's Nav2 implementation provides the HandleNavResults composable.
This block listens to the savedStateHandle of the current backStackEntry and maps raw results into your typed MainNavigationResult events. This allows you to have a app wide list of events (NavigationResult) that does not depend on any feature because you dont want to build relations between feature.
composable<AppScreen.Main> { backStackEntry ->
val viewModel = koinViewModel<MainViewModel>()
HandleNavResults(
handle = backStackEntry.savedStateHandle,
onResult = viewModel::onNavResultReceived
) {
// Register the expected results
OnResult(
navResult = NavigationResult.ExampleNavResult,
mapper = { count ->
// 'count' is automatically cast to Int here because ExampleNavResult is Key<Int>
MainNavigationResult.ExampleEvent(count)
}
)
}
MainScreen(viewModel)
}Forge provides a strongly typed, platform-agnostic API for key-value storage. Instead of using "magic strings" and casting generic objects, you define DataEntry objects that carry both the key name and the expected data type.
Key Features:
- Type Safety: Keys define their own type (
Int,String,Boolean, or Custom Objects). - Reactive: Built-in support for observing values as a
Flow. - Serialization: Native support for storing complex objects using
kotlinx.serialization. - Default Values: Every key requires a default value, eliminating null checks in your business logic.
Define your storage schema in a global object. Use the DataEntry factory methods to create typed keys.
@Serializable
data class UserSession(val token: String, val expiry: Long)
object AppDataEntry {
// 1. Primitive Types
val Count = DataEntry.int(
key = "app_count",
defaultValue = 0
)
val IsOnboarded = DataEntry.boolean(
key = "is_onboarded",
defaultValue = false
)
// 2. Complex Objects (requires kotlinx.serialization)
val Session = DataEntry.serializable(
key = "user_session",
defaultValue = UserSession("", 0L),
serializer = UserSession.serializer(),
)
}Inject the DataStore interface into your UseCases. Because AppDataEntry.Count is defined as a DataEntry<Int>, the set method enforces that you pass an Int.
class DecreaseCounterUseCase(
private val dataStore: DataStore
): FlowOutcomeUseCase<Int, Unit, String>() {
override suspend fun FlowCollector<Outcome<Unit, String>>.execute(params: Int) {
emitSuccess(dataStore.set(AppDataEntry.Count, params - 1))
}
}The observe function returns a Flow<T>. It automatically emits the defaultValue if the key hasn't been written to disk yet.
class ObserveCounterUseCase(
private val dataStore: DataStore
) : UseCase<Unit, Int>() {
override fun execute(params: Unit): Flow<Int> {
// Returns Flow<Int>. Emits 0 immediately if nothing is stored.
return dataStore.observe(AppDataEntry.Count)
}
}Forge comes with the datastore:multiplatform-settings module that implementes the :datastore:api.
Forge uses the androidx DataStore backend for Android, JVM & iOS. On web its a wrapper around LocalStorage.
You should only create a DataStore as singleton and inject it into all the classes that need it.
Creating multible instances will break the observing for the web targets.
You can create it like this when using the multiplatform-settings
This class accepts a custom kotlinx Json when you want to define your own rules.
MultiplatformSettingsDataStore()Forge provides a purely abstract HTTP client. This allows your Domain and Data layers to define network interactions without depending on a specific implementation like Ktor or OkHttp.
The Network API is granular, split into multiple modules so you can include only what you need:
client: The coreHttpClientinterface and request builder.request: Data classes for header, http method, route, ApiRequest. This gets used by the client.response: TheApiResponsesealed classes and functional operators.session: Interfaces for token management and session expiration.
The HttpClient is designed to handle the request execution and the response mapping in a single atomic operation. This keeps your Repositories clean and purely functional.
class DummyRepositoryImpl(
private val httpClient: HttpClient
) : DummyRepository {
override suspend fun fetchDummyPosts(): ApiResponse<List<DummyPost>> =
httpClient.execute<DummyPostResponse, List<DummyPost>>(
requestBuilder = {
// The builder supports all standard verbs: get, post, put, patch, delete
get("https://dummyjson.com/posts")
},
mapper = { response ->
response.posts.map { it.toDummyPost() }
}
)
}The ApiRequestBuilder provides a fluent DSL for constructing requests. All HTTP verbs (get, post, put, delete, patch) provide overloads to accept either a raw string URL or a type-safe Route object.
Using Route objects (usually sealed classes) is recommended for complex APIs to ensure type safety and centralized path management.
httpClient.execute<UserDto, User>(
requestBuilder = {
// Option A: Raw String
// post("https://api.example.com/users")
// Option B: Type-Safe Route (Recommended)
post(UserRoutes.Create)
// Type-safe body serialization
body(CreateUserRequest(name = "John"))
// formdata serialization
formData {
add("key", "value")
}
// Headers & Parameters
header("Authorization", "Bearer xyz")
parameters {
put("sort", "desc")
}
},
mapper = { it.toDomain() }
)The ApiResponse is a sealed class (Success | Error). Forge provides functional operators to chain, transform, and combine responses without "Callback Hell."
map: Transform the success body.
flatMap: Chain a second API call dependent on the first.
onSuccess: Perform a side-effect (like logging or caching).
zip: Run multiple requests in parallel and combine the results.
class LoadDashboardUseCase(
private val userRepository: UserRepository,
private val walletRepository: WalletRepository,
private val analytics: AnalyticsService
) : FlowOutcomeUseCase<Unit, DashboardUiModel, DashboardError>() {
override suspend fun FlowCollector<Outcome<DashboardUiModel, DashboardError>>.execute(params: Unit) {
// 1. Start with the User Profile (Sequential)
val result = userRepository.fetchProfile()
.flatMap { profile ->
// 2. Now that we have the profile, fetch dependent data in PARALLEL
zip(
{ walletRepository.fetchBalance(profile.accountId) },
{ walletRepository.fetchTransactions(profile.accountId) }
) { balance, transactions ->
// 3. Combine all data into a Domain Model
DashboardDomainModel(profile, balance, transactions)
}
}
.onSuccess { dashboard ->
// 4. Side Effect: Log success without altering the data flow
analytics.logEvent("dashboard_loaded", mapOf("user_id" to dashboard.profile.id))
}
.map { domainModel ->
// 5. Transform Domain Model -> UI Model
DashboardUiModel(
welcomeMessage = "Hello, ${domainModel.profile.firstName}",
balance = "$${domainModel.balance.amount}",
recentActivity = domainModel.transactions.take(5)
)
}
// 6. Emit the result (The UseCase handles the Outcome wrapping automatically)
emitFrom(result) { apiError ->
// Map HTTP errors to Domain errors if needed
DashboardError.LoadFailed(apiError.reason)
}
}
}Forge provides a API to append and refresh bearer tokens. For this you simply need to implement the TokenAuthenticator interface.
class TokenAuthenticatorImpl(
private val dataStore: DataStore,
private val sessionManagerHandler: SessionExpirationHandler,
) : TokenAuthenticator {
override suspend fun loadTokens(authApiClient: ApiClient): AuthTokens? {
val access = dataStore
.get(AppDataEntry.AuthToken)
.takeUnless { it.isNullOrBlank() } ?: return null
val refresh = dataStore
.get(AppDataEntry.RefreshToken)
.takeUnless { it.isNullOrBlank() } ?: return null
return AuthTokens(
bearer = access,
refresh = refresh
)
}
override suspend fun refreshTokens(authApiClient: ApiClient): AuthTokens? {
val refresh = dataStore
.get(AppDataEntry.RefreshToken)
.takeUnless { it.isNullOrBlank() } ?: return null
val response = authApiClient.execute<TokenRefreshResponse, TokenRefresh>(
requestBuilder = {
method(HttpMethod.Get)
route(Api.Auth.Refresh)
header(HeaderValue.Bearer(refresh))
},
mapper = { it.toRefreshToken() },
}.onSuccess { refresh ->
dataStore.set(AppDataEntry.AuthToken, refresh.body.token)
dataStore.set(AppDataEntry.RefreshToken, refresh.body.refresh)
}
when (response) {
is ApiResponse.Error -> {
sessionManagerHandler.onSessionExpired()
return null
}
is ApiResponse.Success -> return AuthTokens(
bearer = response.body.token,
refresh = response.body.refresh
)
}
}
}Register it on the client with the authentication function.
Forge will now append bearers to every request when loadTokens returns a Token and automaticly trigger refreshTokens once the token expires.
Forge provides a implementation of the network api using ktor. You can configure it like that
KtorApiClient {
authentication(get())
defaultHeaders {
append("X-App-Version", "1.0.0")
}
}This returns a HttpClientBundle. The bundle contains the HttpClient and a instance of the StateClearable
You can ignore the StateClearable unless you use the authentication API with the Ktor Client.
In this case the ktor modules provides a KtorClientStateClearer which clears Ktors internal authProvider state.
You'd need to call this when you want to logout a user for example
To maintain architectural integrity, follow these strict dependency rules:
-
Domain Modules:
- ✅ Depends on
network-api,datastore-api,usecase,outcome. - ❌ Never depends on
network-ktorordatastore-multiplatformsettings.
- ✅ Depends on
-
Data Modules:
- ✅ Depends on Domain modules and Forge APIs.
- ❌ Never depends on implementation modules. It receives the implementation instances via the constructor.
-
App Module (The Root):
- ✅ Depends on everything.
- This is where you import
network-ktorand bind it to theHttpClientApiinterface in your Dependency Injection graph.