Skip to content

Writing Custom Systems

Stove's built-in systems cover databases, Kafka, HTTP, gRPC, and more, but your application is unique. Maybe you use a job scheduler, publish domain events, need to control time in tests, or talk to a service over a custom protocol. Custom systems let you bring anything into the Stove DSL so your tests read like this:

test("should send welcome email after user signs up") {
    stove {
        http {
            post<UserResponse>("/users", createUserRequest) { it.status shouldBe 201 }
        }

        tasks {
            shouldBeExecuted<SendEmailPayload>(atLeastIn = 10.seconds) {
                recipientEmail == "new-user@example.com"
            }
        }
    }
}

That tasks { } block is a custom system. Building one is straightforward.

The Pattern

Every custom system has three pieces:

1. The System Class

Implement PluggedSystem and pick a lifecycle interface that fits your needs:

class DbSchedulerSystem(
    override val stove: Stove
) : PluggedSystem, AfterRunAwareWithContext<ApplicationContext> {

    private lateinit var listener: StoveDbSchedulerListener

    override suspend fun afterRun(context: ApplicationContext) {
        listener = context.getBean(StoveDbSchedulerListener::class.java)
    }

    suspend inline fun <reified T : Any> shouldBeExecuted(
        atLeastIn: Duration = 5.seconds,
        noinline condition: T.() -> Boolean
    ): DbSchedulerSystem {
        listener.waitUntilObserved(atLeastIn, T::class, condition)
        return this
    }

    override fun close() {}
}

The lifecycle interfaces control when your system runs: before the app starts, after it starts, or when configuration is collected.

Interface When Called
RunAware Before application starts
AfterRunAware After application starts
AfterRunAwareWithContext<T> After application starts, with DI context (e.g., Spring ApplicationContext)
ExposesConfiguration When collecting configuration to pass to the application

2. DSL Extensions

Two extension functions wire your system into Stove's DSL:

@StoveDsl
fun WithDsl.dbScheduler(): Stove =
    this.stove.getOrRegister(DbSchedulerSystem(this.stove)).let { this.stove }

@StoveDsl
suspend fun ValidationDsl.tasks(
    validation: suspend DbSchedulerSystem.() -> Unit
): Unit = validation(this.stove.getOrNone<DbSchedulerSystem>().getOrElse {
    throw SystemNotRegisteredException(DbSchedulerSystem::class)
})

The first one registers the system during setup (.with { dbScheduler() }). The second one exposes it during tests (tasks { ... }).

3. Bean Registration

If your system needs a component inside the application (like a listener), register it as a test bean:

springBoot(
    runner = { params ->
        runApplication<MyApp>(*params) {
            addTestDependencies {
                bean<StoveDbSchedulerListener>(isPrimary = true)
            }
        }
    }
)

That's the whole pattern. The rest is your domain logic.

Ideas

Here are examples of what you can build. Each shows the test DSL (the part your teammates will see), not the implementation details.

Scheduled Task Testing

Test that your application scheduled and executed a task with the expected payload:

stove {
    http {
        postAndExpectBodilessResponse("/orders", body = orderRequest.some()) {
            it.status shouldBe 200
        }
    }

    tasks {
        shouldBeExecuted<SendOrderConfirmationPayload>(atLeastIn = 10.seconds) {
            orderId == expectedOrderId && recipientEmail == "customer@example.com"
        }
    }
}

Full working example

See the spring-showcase recipe for the complete DbSchedulerSystem implementation with reporting integration.

Domain Event Capture

Capture Spring application events in memory and assert on them:

stove {
    http {
        post<UserResponse>("/users", createUserRequest) { it.status shouldBe 201 }
    }

    domainEvents {
        shouldBePublished<UserCreatedEvent>(atLeastIn = 5.seconds) {
            userId == expectedId && name == "John"
        }

        shouldNotBePublished<UserDeletedEvent> {
            userId == expectedId
        }
    }
}

The system behind this is a @EventListener bean that collects events into a ConcurrentLinkedQueue, and a DomainEventSystem that polls it with a timeout.

Time Control

Replace your application's Clock with a test-controllable one:

stove {
    http {
        post<SessionResponse>("/login", credentials) { sessionId = it.sessionId }
    }

    time {
        advance(31.minutes)
    }

    http {
        getResponseBodiless("/protected", headers = mapOf("Session-ID" to sessionId)) {
            it.status shouldBe 401  // Session expired
        }
    }
}

The system injects a StoveTestClock (extending java.time.Clock) as a Spring bean, and the advance() / setTime() methods manipulate it.

Exposing Configuration

If your system starts infrastructure (like a container) and needs to pass connection details to the application:

class MySystem(
    override val stove: Stove,
    private val options: MySystemOptions
) : PluggedSystem, RunAware, ExposesConfiguration {

    private lateinit var config: MyExposedConfig

    override suspend fun run() {
        config = MyExposedConfig(host = "localhost", port = startContainer())
    }

    override fun configuration(): List<String> =
        options.configureExposedConfiguration(config)

    override fun close() {}
}

Stove collects all configuration() outputs and passes them to the application as startup parameters.

Extending Built-In Systems

You don't always need a full system. Sometimes an extension function on an existing system is enough:

@StoveDsl
suspend fun KafkaSystem.publishWithCorrelationId(
    topic: String,
    message: Any,
    correlationId: String = UUID.randomUUID().toString()
) {
    publish(
        topic = topic,
        message = message,
        headers = mapOf("X-Correlation-ID" to correlationId)
    )
}

// Usage
kafka {
    publishWithCorrelationId("orders.created", orderEvent)
}

This works for any built-in system: HttpSystem, KafkaSystem, PostgresqlSystem, etc. Use @StoveDsl for IDE auto-completion support.