Skip to content

Commit a5be306

Browse files
anderssvclaude
andcommitted
Refactor SystemContext to use interfaces instead of open classes
Constructor parameters in open classes forced test subclasses to provide dummy dependencies (DataSource, Config) even when fakes never used them. This became a real problem as repositories evolved to need more dependencies. Changes: - SystemContext: Use interfaces with anonymous objects for dependency groups - SystemTestContext: Implement typed test pattern with dual access paths - Tests updated to use testRepositories/testClients for fake-specific methods - Documentation expanded to explain why open classes didn't work - Skills README enhanced with detailed skill descriptions - Parent README updated with skills section Benefits: - No constructor parameters to satisfy in tests - No casting needed to access fake methods - Type-safe dual access: repositories (interface) vs testRepositories (concrete) - Production wiring stays in one place (anonymous objects) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent eb3e857 commit a5be306

7 files changed

Lines changed: 292 additions & 39 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ But special thanks goes out to Asgaut Mjølne, Ola Hast, and Terje Heen for the
3232

3333
If you're looking for the workshop, you can [find it here](doc/workshop/README.md).
3434

35+
# Claude Code Skills
36+
37+
This repository includes reusable [Claude Code skills](skills/README.md) that teach Claude the patterns and practices used here:
38+
39+
- **kotlin-tdd** 🧪 - Test-Driven Development with fakes, object mothers, and Testing Through The Domain
40+
- **kotlin-context-di** 🔌 - Manual dependency injection using SystemContext and TestContext patterns
41+
42+
Install them to teach Claude Code about the approaches in this codebase:
43+
```bash
44+
npx skills add anderssv/the-example/skills
45+
```
46+
3547
# Using this code and building
3648

3749
## Prerequisites

doc/manual-dependency-injection.md

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,89 @@ I usually do manual dependency injection. 🚀
2121
> ✅ You can see a dependency injection example in [SystemContext.kt](../src/main/kotlin/system/SystemContext.kt)
2222
and how to set up a separate context for testing in [SystemTestContext.kt](../src/test/kotlin/system/SystemTestContext.kt).
2323

24+
## The Pattern: Interfaces with Anonymous Objects
2425

25-
My reasons for this are:
26+
The core pattern uses **interfaces** for dependency grouping, implemented as **anonymous objects** inside the context:
27+
28+
```kotlin
29+
interface Repositories {
30+
val applicationRepo: ApplicationRepository
31+
}
32+
33+
open val repositories: Repositories by lazy {
34+
object : Repositories {
35+
override val applicationRepo by lazy { ApplicationRepositoryImpl() }
36+
}
37+
}
38+
```
39+
40+
### Why Interfaces Instead of Open Classes?
41+
42+
**The problem with open classes:**
43+
```kotlin
44+
// Don't do this
45+
open class Repositories(private val dataSource: DataSource) {
46+
open val applicationRepo: ApplicationRepository = ApplicationRepositoryImpl(dataSource)
47+
}
48+
```
49+
50+
When you use open classes with constructor parameters, test subclasses are forced to satisfy those parameters even when test fakes never use them:
51+
52+
```kotlin
53+
// Test subclass is forced to provide a dataSource it doesn't need
54+
class TestRepositories : Repositories(DummyDataSource()) { // <- Awkward!
55+
override val applicationRepo = ApplicationRepositoryFake() // Doesn't even use dataSource!
56+
}
57+
```
58+
59+
This became a real problem in production code when repositories started needing database connections, configuration objects, or HTTP clients in their constructors. Test code had to create dummy instances of these just to satisfy the superclass constructor, even though fakes are in-memory and never touch databases or HTTP.
60+
61+
**The interfaces solution:**
62+
- No constructor parameters
63+
- No dummy objects needed in tests
64+
- Test implementations must explicitly provide every dependency (no hidden inherited behavior)
65+
- Production wiring stays in one place (anonymous objects inside SystemContext)
66+
67+
### Typed Test Implementations Pattern
68+
69+
The test context provides **two access paths** using Kotlin's covariant return types:
70+
71+
```kotlin
72+
class SystemTestContext : SystemContext() {
73+
class TestRepositories : Repositories {
74+
override val applicationRepo = ApplicationRepositoryFake() // Concrete type!
75+
}
76+
77+
val testRepositories = TestRepositories()
78+
override val repositories: Repositories get() = testRepositories
79+
}
80+
```
81+
82+
In tests:
83+
- `repositories.applicationRepo` - typed as `ApplicationRepository` (interface, used by production code)
84+
- `testRepositories.applicationRepo` - typed as `ApplicationRepositoryFake` (concrete, for test assertions)
85+
86+
No casting needed:
87+
```kotlin
88+
with(SystemTestContext()) {
89+
applicationService.registerApplication(app)
90+
91+
// Access fake-specific methods directly - no casting!
92+
assertThat(testRepositories.applicationRepo.getAllApplications())
93+
.contains(app)
94+
}
95+
```
96+
97+
## Benefits of Manual DI
98+
99+
My reasons for this approach:
26100
- I spend less time on Google figuring out which annotation or XML/JSON/YAML element to specify.
27101
- It shows the different places I am injecting each object/service/repo/client.
28102
- It makes it possible to inject whatever I want, whenever I want. Think Test Doubles like Fakes. Or even once in a while Mocks.
29-
- I TDD a lot more. Because I control how much is being loaded and when, I only load whats necessary and its fast. I can decide to make the feedback loop efficient even if I am writing automated tests that do actual HTTP and database calls.
103+
- I TDD a lot more. Because I control how much is being loaded and when, I only load what's necessary and it's fast. I can decide to make the feedback loop efficient even if I am writing automated tests that do actual HTTP and database calls.
30104
- I re-factor more. Sometimes when I re-factor, the specific patterns of the framework get in the way. Without framework annoyances, it is easier and more fun to do refactorings.
105+
- No casting needed in tests (typed test implementations give you concrete fake types)
106+
- Test contexts are lightweight - fresh context per test prevents state leakage
31107

32108
# Related Reading
33109
- [Rolling your own dependency injection](https://anderssv.medium.com/rolling-your-own-dependency-injection-7045f8b64403)

skills/README.md

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,71 @@
1-
Skills :-)
1+
# Claude Skills for The Example
22

3-
npx skills add https://github.com/anderssv/the-example/skills
3+
This directory contains reusable Claude Code skills that teach Claude Code patterns and practices used in this project.
4+
5+
## Installation
6+
7+
Add these skills to your Claude Code installation:
8+
9+
```bash
10+
npx skills add anderssv/the-example/skills
11+
```
12+
13+
## Available Skills
14+
15+
### 🧪 kotlin-tdd
16+
17+
Kotlin Test-Driven Development with fakes, object mothers, and Testing Through The Domain.
18+
19+
**Location:** `testing/kotlin-tdd/`
20+
21+
**What it teaches:**
22+
- Three pillars: Test Setup, Fakes, and Testing Through The Domain (TTTD)
23+
- Extension functions for test data with sensible defaults
24+
- HashMap-based fakes instead of mocking frameworks
25+
- SystemTestContext pattern with dependency injection
26+
- TestClock for time control in tests
27+
- Parallel-safe assertions for concurrent test execution
28+
- Test tagging (unit, integration, database, e2e)
29+
- When to use real implementations vs fakes
30+
- When mocks are appropriate (HTTP protocol testing)
31+
32+
**Use when:**
33+
- Writing Kotlin tests
34+
- Setting up test infrastructure
35+
- Need guidance on fakes vs mocks
36+
- Testing time-dependent code
37+
- Writing parallel-safe tests
38+
39+
### 🔌 kotlin-context-di
40+
41+
Manual dependency injection using SystemContext (production) and TestContext (test doubles) patterns.
42+
43+
**Location:** `practices/kotlin-context-di/`
44+
45+
**What it teaches:**
46+
- SystemContext pattern for type-safe DI without frameworks
47+
- TestContext pattern for test doubles
48+
- Using interfaces for dependency grouping
49+
- Anonymous objects for production wiring
50+
- Typed test implementations to avoid casting
51+
- Fresh context per test pattern
52+
- Lazy vs direct initialization strategies
53+
- Nullable-to-non-nullable narrowing in tests
54+
55+
**Use when:**
56+
- Structuring service dependencies
57+
- Wiring application components
58+
- Creating test contexts
59+
- Need alternative to framework-based DI
60+
- Want full control over initialization
61+
62+
## Why These Skills?
63+
64+
These skills codify the patterns and practices demonstrated throughout this repository. They help Claude Code understand:
65+
- How to write tests using the approaches shown in `/doc/tdd.md`, `/doc/fakes.md`, and `/doc/tttd.md`
66+
- How to structure applications using the patterns in `/doc/manual-dependency-injection.md`
67+
- The specific idioms and conventions used in this codebase
68+
69+
## Contributing
70+
71+
These skills are extracted from real working code in this repository. If you find improvements or want to add new skills, please open an issue or PR.

src/main/kotlin/system/SystemContext.kt

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,79 @@ import notifications.UserNotificationClientImpl
1212
import java.time.Clock
1313

1414
/**
15-
* This represents the main DI context.
15+
* Manual dependency injection using interfaces and anonymous objects.
1616
*
17-
* Using lazy for the implementations here as we don't want them to load if overridden.
18-
* This can be important for third party integrations and DBs.
19-
* If you know a better way, please let me know.
17+
* This pattern uses interfaces for dependency grouping instead of open classes with constructor parameters.
18+
* The reason: open classes with constructor parameters force test subclasses to satisfy those parameters,
19+
* even when test fakes never use them (e.g., creating a dummy DataSource just to satisfy a constructor).
2020
*
21-
* See here for a subclass that overrides the relevant parts with fakes (in the test scope):
21+
* Benefits of using interfaces:
22+
* - No constructor parameters to satisfy in test subclasses
23+
* - Test implementations can have concrete types (e.g., ApplicationRepositoryFake instead of ApplicationRepository)
24+
* - No casting needed in tests to access fake-specific methods
25+
* - Production wiring stays in one place (anonymous objects inside this context)
26+
*
27+
* See the test context that provides fakes:
2228
* https://github.com/anderssv/the-example/blob/main/src/test/kotlin/system/SystemTestContext.kt
2329
*/
24-
@Suppress("LeakingThis")
25-
open class SystemContext { // You can pass things like config and DB in here, YMMV
26-
// Just some namespacing
27-
open class Repositories {
28-
// Can be overridden in the subclass
29-
open val applicationRepo: ApplicationRepository by lazy { ApplicationRepositoryImpl() }
30+
open class SystemContext { // You can pass things like config and DB in here
31+
/**
32+
* Interface for repository dependencies.
33+
* Using an interface (not an open class) means:
34+
* - No constructor parameters
35+
* - Test implementations don't inherit production defaults
36+
* - Tests must explicitly provide every dependency
37+
*/
38+
interface Repositories {
39+
val applicationRepo: ApplicationRepository
40+
}
41+
42+
/**
43+
* Interface for external client dependencies.
44+
* Using an interface (not an open class) means:
45+
* - No constructor parameters
46+
* - Test implementations don't inherit production defaults
47+
* - Tests must explicitly provide every dependency
48+
*/
49+
interface Clients {
50+
val customerRepository: CustomerRegisterClient
51+
val userNotificationClient: UserNotificationClient
52+
val brregClient: BrregClient
53+
}
54+
55+
/**
56+
* Production implementation of repositories using anonymous objects.
57+
* The anonymous object captures context properties (like dataSource if we had one).
58+
* Using lazy prevents initialization if overridden in test contexts.
59+
*/
60+
open val repositories: Repositories by lazy {
61+
object : Repositories {
62+
override val applicationRepo: ApplicationRepository by lazy { ApplicationRepositoryImpl() }
63+
}
3064
}
3165

32-
// Just some namespacing
33-
open class Clients {
34-
open val customerRepository: CustomerRegisterClient by lazy { CustomerRegisterClientImpl() }
35-
open val userNotificationClient: UserNotificationClient by lazy { UserNotificationClientImpl() }
36-
open val brregClient: BrregClient by lazy { BrregClientImpl() }
66+
/**
67+
* Production implementation of clients using anonymous objects.
68+
* The anonymous object captures context properties (like config if we had one).
69+
* Using lazy prevents initialization if overridden in test contexts.
70+
*/
71+
open val clients: Clients by lazy {
72+
object : Clients {
73+
override val customerRepository: CustomerRegisterClient by lazy { CustomerRegisterClientImpl() }
74+
override val userNotificationClient: UserNotificationClient by lazy { UserNotificationClientImpl() }
75+
override val brregClient: BrregClient by lazy { BrregClientImpl() }
76+
}
3777
}
3878

39-
// Open because they will be overridden with Fakes
40-
open val repositories = Repositories()
41-
open val clients = Clients()
79+
/**
80+
* Clock for time-based operations. Overridden with TestClock in tests.
81+
*/
4282
open val clock: Clock = Clock.systemDefaultZone()
4383

44-
// The main components using the IO stuff that can be faked
45-
// Using lazy here to get the overridden values from the subclass that overrides clients and repos
84+
/**
85+
* The main application service using the IO dependencies.
86+
* Using lazy here ensures we get the overridden values from test subclasses.
87+
*/
4688
val applicationService by lazy {
4789
ApplicationService(
4890
repositories.applicationRepo,

src/test/kotlin/application/ApplicationFakeTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,15 @@ class ApplicationFakeTest {
7070
applicationService.expireApplications()
7171

7272
assertThat(applicationService.activeApplicationFor(application.name)).isEmpty()
73-
clients.userNotificationClient.getNotificationForUser(application.name).also {
73+
testClients.userNotificationClient.getNotificationForUser(application.name).also {
7474
// Notice how this is a specific method in the Fake. In the case of something
7575
// like e-mail, there is no way of fetching the actual messages after the fact.
7676
// So this method is used to verify the outcome, which should be that notifications
7777
// are sent to the user.
7878
//
7979
// Try to focus on verifying the system results, but sometimes you need to validate
8080
// the interactions as well.
81-
assertThat(it).isNotEmpty
81+
assertThat(it).isNotEmpty()
8282
assertThat(it).contains("Your application ${application.id} has expired")
8383
}
8484
}

src/test/kotlin/system/SystemTestContext.kt

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,84 @@ import customer.CustomerRegisterClientFake
66
import notifications.UserNotificationClientFake
77

88
/**
9-
* Overrides the relevant properties to make them Fakes
9+
* Test context with fakes using typed test implementations pattern.
1010
*
11-
* Notice that this Context only overrides the Repos/Clients with fakes.
12-
* The actual injection of Services (that I usually don't fake) is done in the superclass via the lazy construct.
11+
* This pattern provides two access paths:
12+
* 1. repositories.applicationRepo - typed as ApplicationRepository (interface type, used by services)
13+
* 2. testRepositories.applicationRepo - typed as ApplicationRepositoryFake (concrete type, for test assertions)
1314
*
14-
* See here for the super class that this test context inherits/overrides:
15+
* Benefits:
16+
* - No casting needed to access fake-specific methods
17+
* - Type safety: IDE autocomplete shows fake methods when using testRepositories
18+
* - Clear separation: production code uses abstract interfaces, tests use concrete fakes
19+
*
20+
* Usage in tests:
21+
* ```
22+
* with(SystemTestContext()) {
23+
* // Arrange
24+
* applicationService.registerInitialApplication(customer, application)
25+
*
26+
* // Assert - direct access to fake methods, no casting needed
27+
* assertThat(testRepositories.applicationRepo.getAllApplications())
28+
* .contains(application)
29+
* }
30+
* ```
31+
*
32+
* See the production context:
1533
* https://github.com/anderssv/the-example/blob/main/src/main/kotlin/system/SystemContext.kt
1634
*
17-
* To see usage, you can see here: https://github.com/anderssv/the-example/blob/main/src/test/kotlin/tttd/TestingThroughTheDomainTest.kt#L26
35+
* See usage examples:
36+
* https://github.com/anderssv/the-example/blob/main/src/test/kotlin/application/TestingThroughTheDomainTest.kt
1837
*/
1938
class SystemTestContext : SystemContext() {
20-
class Repositories : SystemContext.Repositories() {
39+
/**
40+
* Test implementation with concrete fake types.
41+
* Properties are typed as ApplicationRepositoryFake (not ApplicationRepository),
42+
* which allows accessing fake-specific methods without casting.
43+
*/
44+
class TestRepositories : Repositories {
2145
override val applicationRepo = ApplicationRepositoryFake()
2246
}
2347

24-
class Clients : SystemContext.Clients() {
48+
/**
49+
* Test implementation with concrete fake types.
50+
* Properties are typed as *ClientFake (not the interface),
51+
* which allows accessing fake-specific methods without casting.
52+
*/
53+
class TestClients : Clients {
2554
override val customerRepository = CustomerRegisterClientFake()
2655
override val userNotificationClient = UserNotificationClientFake()
2756
override val brregClient = BrregClientFake()
2857
}
2958

30-
// Override the contexts with Fakes
31-
override val repositories = Repositories()
32-
override val clients = Clients()
59+
/**
60+
* Typed test repositories - use this in test assertions to access fake-specific methods.
61+
* Type: TestRepositories (concrete class with ApplicationRepositoryFake properties)
62+
*/
63+
val testRepositories = TestRepositories()
64+
65+
/**
66+
* Typed test clients - use this in test assertions to access fake-specific methods.
67+
* Type: TestClients (concrete class with *ClientFake properties)
68+
*/
69+
val testClients = TestClients()
70+
71+
/**
72+
* Override abstract interface property with test implementation.
73+
* Production code accesses this as Repositories (abstract interface).
74+
* Type: Repositories (interface)
75+
*/
76+
override val repositories: Repositories get() = testRepositories
77+
78+
/**
79+
* Override abstract interface property with test implementation.
80+
* Production code accesses this as Clients (abstract interface).
81+
* Type: Clients (interface)
82+
*/
83+
override val clients: Clients get() = testClients
84+
85+
/**
86+
* Override clock with TestClock for time control in tests.
87+
*/
3388
override val clock = TestClock.now()
3489
}

0 commit comments

Comments
 (0)