Target audience: AI agents and developers.
AI agents should treat this document as a strict ruleset when generating, reviewing, or refactoring code that follows the PTSr pattern.
Developers should use it as a design and onboarding guide.
- Overview
- Core Layers
- Levels of Abstraction
- Architecture Elements
- Naming Conventions
- PTSr ↔ DDD Mapping
- Pros & Cons
PTSr stands for Presentation – Task – Solution – result.
The lowercase r ("achieved result") is intentional: it is not a separate code layer but the outcome of executing a
Task through a Solution. The name captures the full lifecycle of a user-driven action: the UI (Presentation)triggers
a Task, the Task is fulfilled by a Solution, and a result is reflected back in the UI.
"The UI holds state and delegates every action to a Task.
Tasks are contracts. Solutions are implementations. Di wires them together.
Nothing leaks across layer boundaries."
Solutionsare always feature-private — they must never be referenced fromPresentationor exposed outside the feature boundary.Presentationonly seesModelsandTasks(and optionallyDi). It must not instantiate or import anySolutionclass.Tasksare interfaces — thePresentationlayer is always programming to an abstraction.- No
ViewModelor equivalent lifecycle-owner class is used. State is retained via theretainmechanism in theBindingcomposable. - The architecture is designed for Jetpack Compose UIs only.
| Layer | Role | Depends On | Visibility |
|---|---|---|---|
| Models | Data definitions: data classes, enums, sealed classes, etc. | Nothing | Public within feature. Used by Presentation and Tasks. |
| Tasks | Interfaces describing what the feature can do. One Task = one concern. | Models (via api in multi-module) |
Public within feature. Used by Presentation. |
| Solutions | Classes that implement Tasks. Contain repositories, API calls, caches, auto-DI (Hilt), and business logic. | Models, Tasks | Feature-private. Never exposed outside the feature boundary. Only the Di module may use it. |
| Di (dependencies) | Optional. Dependency injection / service-locator wiring layer. Not needed when using auto-DI (Hilt, etc.). | Solutions, Tasks, Models | Feature-private. Acts as the composition root of the feature. |
| Presentation | Compose UI layer. Holds UI state via retain. Delegates actions to Tasks. | Models, Tasks, Di (if present) | Public. The only layer visible to the outside world. |
Arrows represent "depends on / uses":
+----------+
| Models |
+----------+
^ ^
| |
+-------+ |
+->| Tasks | |
| +-------+ |
| ^ ^ |
| | | |
| | +------------+
| | | Solutions | (feature-private)
| | +------------+
| | ^
| | |
| +---+---+
| |
| +───────+
| | Di | (optional, feature-private)
| +───────+
| ^
| |
| +────────────────+
+----| Presentation | (public)
+────────────────+
Presentation depends on Di (optional) and directly on Tasks; Models are visible transitively via Tasks'
apidependency.
Di is optional. When auto-DI (e.g. Hilt) is used, Presentation does not depend on Di directly.
The architecture defines four levels. Higher levels increase isolation and reusability at the cost of more boilerplate. Choose the level that matches your project's size and team structure.
| Level | Name | When to Use |
|---|---|---|
| L1 | All in One | Small features, prototypes, solo dev |
| L2 | Basic Modularization | Team features, medium complexity |
| L3 | Compact Modularization | Preference for fewer modules |
| L4 | Shareable Modularization | Cross-feature shared contracts; auto-DI framework (e.g. Hilt) in use |
All code lives in a single Gradle module. Layer boundaries are enforced through Kotlin visibility modifiers (internal, private), not module boundaries.
Rule: Only Presentation types (and any Models they expose in their public signatures) are public. All other layers are internal or private.
Package structure (feature root: com.example.feature):
com.example.feature/
├── models/ (internal) -- data classes, enums, sealed classes
├── tasks/ (internal) -- interfaces
├── solutions/ (internal) -- implementations, repositories
├── di/ (internal) -- optional service locator
└── presentation/ (public) -- Composable screens, Binding + UI
Code is split into separate Gradle modules. Only the feature (root) module is a dependency for other features. The root module aggregates all sub-modules.
The feature module depends on all sub-modules and is the public entry point for the feature. It exposes only thepresentation module.
Rule: All sub-modules except presentation are feature-private and must not appear in external api orimplementation dependency graphs.
Module structure (feature root: com.example.feature):
feature/
├── models/ com.example.feature.models
├── tasks/ com.example.feature.tasks
├── solutions/ com.example.feature.solutions
├── di/ com.example.feature.di [optional]
└── presentation/ com.example.feature.presentation
Module dependency map:
| Module | Depends On | Type |
|---|---|---|
models |
(none) | |
tasks |
:feature:models |
api |
solutions |
:feature:tasks |
implementation |
di |
:feature:tasks |
implementation |
:feature:solutions |
implementation |
|
presentation |
:feature:tasks |
implementation |
:feature:di (optional) |
implementation |
|
feature (root) |
:feature:presentation |
api |
:feature:models |
implementation |
|
:feature:tasks |
implementation |
|
:feature:solutions |
implementation |
|
:feature:di (optional) |
implementation |
Follows all rules of Level 2. Two structural changes:
models+tasksare merged into a singledomainmodule with two sub-packages.solutionsis renamed todatato better reflect its repository/data-source role.
Module structure:
feature/
├── domain/ com.example.feature.domain [MODULE]
│ ├── models/ com.example.feature.domain.models [PACKAGE]
│ └── tasks/ com.example.feature.domain.tasks [PACKAGE]
├── data/ com.example.feature.data [feature-private, replaces solutions]
├── di/ com.example.feature.di [optional]
└── presentation/ com.example.feature.presentation
Module dependency map:
| Module | Depends On | Type |
|---|---|---|
domain |
(none) | |
data |
:feature:domain |
implementation |
di |
:feature:domain |
implementation |
:feature:data |
implementation |
|
presentation |
:feature:domain |
implementation |
:feature:di (optional) |
implementation |
|
feature (root) |
:feature:presentation |
api |
:feature:domain |
implementation |
|
:feature:data |
implementation |
|
:feature:di (optional) |
implementation |
Follows all rules of Level 2. Adds up to three shared-* modules that expose contracts (Models, Tasks) and
shared UI usable by multiple other features. All shared modules are optional —
include only the ones your cross-feature contract requires. Shared modules never contain solution/implementation code —
implementations always live in the solutions module (feature-private).
No manual Di module is used at L4. An auto-DI framework (e.g. Hilt) is required and is the only supported
wiring mechanism at this level. This is the only level where manual di is explicitly prohibited; L1–L3 continue to
support it as optional.
Module structure:
feature/
├── shared-models/ com.example.feature.shared.models [optional]
├── shared-tasks/ com.example.feature.shared.tasks [optional]
├── shared-presentation/ com.example.feature.shared.presentation [optional]
│
├── models/ com.example.feature.models
├── tasks/ com.example.feature.tasks
├── solutions/ com.example.feature.solutions [feature-private]
└── presentation/ com.example.feature.presentation
Key rules for L4:
modelsdepends onshared-modelsviaapi(when present), andtasksdepends onshared-tasksviaapi(when present), making shared contracts visible transitively to consumers.shared-presentationcomposables receive sharedTasksas lambda or parameter arguments — they must not use the auto-DI framework directly.shared-presentationmay optionally depend onpresentation(implementation) in order to reuse UI components already defined there. Use this only for pure, stateless UI components — noTaskor state logic must leak through.- All auto-DI binding classes (e.g. Hilt
@Module/@InstallIn) live insidesolutions, keeping all wiring feature-private. No binding classes are placed in any other module. - No Hilt entry point or component is needed in
feature(root). Bindings are discovered automatically by the framework via the transitive dependency chain: app →featureroot →solutions.
Module dependency map:
| Module | Depends On | Type |
|---|---|---|
shared-models (optional) |
(none) | |
shared-tasks (optional) |
:feature:shared-models (optional) |
api |
shared-presentation |
:feature:shared-tasks (optional) |
implementation |
:feature:presentation (optional) |
implementation |
|
models |
:feature:shared-models (optional) |
api |
tasks |
:feature:shared-tasks (optional) |
api |
:feature:models |
api |
|
solutions |
:feature:tasks |
implementation |
presentation |
:feature:tasks |
implementation |
feature (root) |
:feature:presentation |
implementation |
:feature:shared-models (optional) |
implementation |
|
:feature:shared-tasks (optional) |
implementation |
|
:feature:shared-presentation (optional) |
implementation |
|
:feature:models |
implementation |
|
:feature:tasks |
implementation |
|
:feature:solutions |
implementation |
PTSr uses a retain mechanism to keep UI state alive for the duration of a screen's presence in the back stack. When the screen leaves the back stack the retained data is released.
Screen in back stack → retained data is alive
Screen popped → retained data is released
retain { }creates or retrieves an instance that survives recompositions and configuration changes but is scoped to the screen lifecycle.- Long-living data producers (network streams, timers) use
Flow,produceState, orproduceRetainedState. inputs(parameters passed from the previous screen) are not retained — they are only used to initialise retained state.
Library note: The base
retain { }function is part of the Kotlin/Compose ecosystem and available without extra dependencies. Extended retain helpers such asproduceRetainedStatemay require an external library ( e.g. composegears/Tiamat, Voyager).Imports:
import androidx.compose.runtime.retain.retain import androidx.compose.runtime.retain.RetainedEffect
Every screen has at least two composable functions:
+---------------------------+ +---------------------------+
| FeatureScreenBinding | | FeatureScreenUI |
|---------------------------| |---------------------------|
| - Retains state | ---> | - Pure UI only |
| - Resolves Tasks via Di | | - Models as params |
| - Calls FeatureScreenUI | | - Callbacks as params |
| - Owns lifecycle logic | | - No logic, no Tasks |
+---------------------------+ | - No state mutation |
+---------------------------+
Rules for Binding:
- May retain
ScreenDataandTaskinstances. - Receives
inputs(non-retained, from navigation) as parameters. - Wires callbacks by delegating to retained Tasks.
Rules for UI:
- Parameters are only
Modeltypes and lambda callbacks. - Must not import or use any
Task. - Must not perform state mutations directly — only invoke provided callbacks.
PTSr does not use ViewModels (or equivalent lifecycle-owner constructs). The reasons:
| ViewModel concern | PTSr equivalent |
|---|---|
| Survive config changes | retain { } in Binding composable |
| Expose state to UI | Retained ScreenData passed to UI composable |
| Execute business logic | Task interface called from Binding callback |
| Scope to screen lifecycle | retain scoped to back-stack entry |
ViewModels add an extra indirection layer that PTSr eliminates by retaining data and tasks directly in the composable scope.
// ── Navigation host (caller) ──────────────────────────────────────────────
<NavigationHost > {
val inputs = ... // e.g. route arguments: productId, userId
FeatureScreenBinding(inputs)
}
// ── Binding composable ────────────────────────────────────────────────────
// Responsibilities:
// 1. Initialise and retain screen state from inputs.
// 2. Retain Task instances (resolved via Di or auto-DI).
// 3. Expose callbacks that delegate to Tasks.
// 4. Call the pure UI composable.
@Composable
fun FeatureScreenBinding(input: FeatureInput) {
// Retained for the screen's lifetime; inputs only used for initialisation.
val uiData: FeatureScreenData = retain { FeatureScreenData(input) }
// Task retained alongside the data.
val doSomethingTask: DoSomethingTask = retain { Di.provideDoSomethingTask() }
// Long-living producer example:
val liveItems by produceRetainedState(initialValue = emptyList()) {
Di.provideGetItemsTask().itemsFlow().collect { value = it }
}
FeatureScreenUI(
uiData = uiData,
items = liveItems,
onDoSomething = { doSomethingTask.doSomething(uiData.selectedItem) },
onNavigateBack = { /* navigation call */ }
)
}
// ── Pure UI composable ────────────────────────────────────────────────────
// Responsibilities:
// - Render Models.
// - Call provided callbacks on user interaction.
// Forbidden:
// - Any import of Task, Solution, Di, or Repository types.
// - Any direct state mutation or business logic.
@Composable
fun FeatureScreenUI(
uiData: FeatureScreenData,
items: List<ItemModel>,
onDoSomething: () -> Unit,
onNavigateBack: () -> Unit
) {
Column {
TopBar(onBack = onNavigateBack)
ItemList(items = items)
Button(onClick = onDoSomething) {
Text("Do Something with ${uiData.selectedItem.name}")
}
}
}Demonstrates naming conventions, fun interface usage, internal repository encapsulation, and Di wiring.
// ── models/UserModel.kt ───────────────────────────────────────────────────
data class UserModel(
val id: String,
val name: String,
val email: String
)
// ── tasks/GetUserTask.kt ──────────────────────────────────────────────────
// Single-function task: use 'fun interface' for SAM conversion convenience.
// Naming: VerbNounTask
fun interface GetUserTask {
suspend fun getUser(userId: String): UserModel
}
// ── tasks/UserTasks.kt ────────────────────────────────────────────────────
// Multi-function task for the same concern (User): use NounTasks naming.
interface UserTasks {
suspend fun getUser(userId: String): UserModel
suspend fun updateUser(user: UserModel)
suspend fun deleteUser(userId: String)
}
// ── solutions/UserRepository.kt ──────────────────────────────────────────
// Internal to the solutions layer. Never referenced from Presentation or Di.
class UserRepository(private val api: UserApi) {
suspend fun fetchUser(userId: String): UserModel = api.getUser(userId)
suspend fun saveUser(user: UserModel) = api.putUser(user)
suspend fun removeUser(userId: String) = api.deleteUser(userId)
}
// ── solutions/UserSolutions.kt ───────────────────────────────────────────
// Implements UserTasks. Depends on internal repository.
// Naming: NounSolutions (multi-function) or VerbNounSolution (single-function).
class UserSolutions(
private val repository: UserRepository
) : UserTasks {
override suspend fun getUser(userId: String): UserModel =
repository.fetchUser(userId)
override suspend fun updateUser(user: UserModel) =
repository.saveUser(user)
override suspend fun deleteUser(userId: String) =
repository.removeUser(userId)
}
// Single-function solution example:
class GetUserSolution(
private val repository: UserRepository
) : GetUserTask {
override suspend fun getUser(userId: String): UserModel =
repository.fetchUser(userId)
}
// ── di/UserDi.kt ──────────────────────────────────────────────────────────
// Service locator wiring. The only place that knows about Solutions.
object UserDi {
private val api by lazy { UserApi() }
private val repository by lazy { UserRepository(api) }
fun provideUserTasks(): UserTasks = UserSolutions(repository)
fun provideGetUserTask(): GetUserTask = GetUserSolution(repository)
}
// ── Presentation usage (Binding) ──────────────────────────────────────────
@Composable
fun UserScreenBinding(userId: String) {
val userTasks: UserTasks = retain { UserDi.provideUserTasks() }
val uiData: UserScreenData = retain { UserScreenData() }
RetainedEffect(userId) {
uiData.user = userTasks.getUser(userId)
}
UserScreenUI(
user = uiData.user,
onDelete = { userTasks.deleteUser(userId) }
)
}For any given concern (domain noun), choose one Task style and apply it consistently across the entire feature.
Mixing VerbNounTask fun interfaces with a NounTasks interface for the same concern is a violation.
- Prefer
NounTasks(Option A) when the functions share state, a common repository, or are always injected together — grouping them communicates that they belong to the same concern and travel together. - Prefer separate
VerbNounTask(Option B) when the actions are independent, individually mockable in tests, or consumed by different screens that only need a subset of the operations.
// AddNoteTask is a single-function fun interface …
fun interface AddNoteTask {
fun addNote(text: String)
}
// … but NotesTasks is a multi-function interface for the same "notes" concern.
// They must not coexist — pick one style.
interface NotesTasks {
val notesFlow: Flow<List<NoteModel>>
fun deleteNote(id: String)
}interface NotesTasks {
val notesFlow: Flow<List<NoteModel>>
fun addNote(text: String)
fun deleteNote(id: String)
}fun interface GetNotesTask {
val notesFlow: Flow<List<NoteModel>>
}
fun interface AddNoteTask {
fun addNote(text: String)
}
fun interface DeleteNoteTask {
fun deleteNote(id: String)
}| Situation | Pattern | Examples |
|---|---|---|
| Task with one function | VerbNounTask — use fun interface |
GetUserTask, UpdateUserTask, DeleteUserTask, FetchProductTask |
| Task with multiple functions (same concern) | NounTasks — regular interface |
UserTasks, ProductTasks, OrderTasks |
| ❌ Mixed concerns in one Task | Violation — split into separate tasks | UserAndProductTaskGetUserAndOrderTask |
❌ VerbNounTask + NounTasks coexisting for the same concern |
Violation — pick one style (see §4.6) | AddNoteTask alongside NotesTasks |
| Prefer | When |
|---|---|
NounTasks |
Functions share a repository, are always injected together, or naturally belong to the same concern. |
VerbNounTask |
Actions are independent, need individual mocking in tests, or are consumed by different screens. |
| Situation | Pattern | Examples |
|---|---|---|
| Implements a single-function Task | VerbNounSolution |
GetUserSolution, UpdateUserSolution, DeleteUserSolution, FetchProductSolution |
| Implements a multi-function Task (same concern) | NounSolutions |
UserSolutions, ProductSolutions, OrderSolutions |
| Part | Pattern |
|---|---|
| Binding composable | FeatureNameScreenBinding(inputs) |
| Pure UI composable | FeatureNameScreenUI(...) |
| Screen data holder (state) | FeatureNameScreenData |
PTSr maps 1-to-1 onto standard DDD layers.
| PTSr | DDD Layer | Notes |
|---|---|---|
| Models + Tasks | Domain Layer | Models = domain data; Tasks = use-case contracts (stateless interfaces, no implementation) |
| Solutions | Data Layer | Repositories, API clients, caches — always feature-private, never exposed |
| Di | Infrastructure | Wires Tasks to Solutions; optional when using auto-DI (Hilt, etc.) |
| Presentation | Presentation Layer | Compose UI only; holds retained state instead of a ViewModel |
| # | Advantage | Detail |
|---|---|---|
| 1 | Clear separation of concerns | Each layer has one job. No logic bleeds into UI; no UI knowledge leaks into the data layer. |
| 2 | High testability | Tasks are interfaces — trivially mockable. Solutions can be unit-tested without UI. Binding composables are thin and predictable. |
| 3 | No ViewModel boilerplate | Eliminates the ViewModel class, its Factory, and its SavedStateHandle plumbing. State is retained directly in composable scope. |
| 4 | Lifecycle-safe retain | Retained objects survive recompositions and configuration changes. Released automatically when the screen is popped from the back stack. |
| 5 | Scalable abstraction levels | Start at L1 for a small feature; migrate to L2/L3/L4 incrementally as the codebase grows. No big-bang refactor required. |
| 6 | Safe public surface | Only Presentation (and its Model parameters) is public. Solutions are always feature-private. Consumers cannot accidentally couple to implementation details. |
| 7 | AI-agent friendly | Strict layer rules and naming conventions make it straightforward for AI agents to generate, review, and validate conforming code automatically. |
| # | Trade-off | Detail |
|---|---|---|
| 1 | Compose-only | The retain mechanism and Binding/UI pattern assume Jetpack Compose. Not applicable to XML/View-based UI. |
| 2 | L4 requires auto-DI framework | Shareable Modularization mandates an auto-DI framework (e.g. Hilt). Manual Di modules are prohibited at this level. |