Target is a library for Functional Domain Modeling in Kotlin, inspired by arrow-kt.
Target aims to provide a set of tools across all Kotlin platforms to empower users to quickly write pure, functionally
validated domain models. For this, it includes a set of atomic components: ValueFailure
, ValueObject
,
and ValueValidator
. These components can be used on their own, or in conjunction with the
included KSP annotation processor.
A ValueFailure
is an interface representing a failure during value validation.
interface ValueFailure<T> {
val failedValue: T
}
A ValueObject
is an interface representing a validated value. By convention, value object implementations have a
private primary constructor, so that they are not instantiated outside a ValueValidator
. A value object implementation
must declare a companion object implementing a value validator when used in conjunction with the annotation processor
library.
interface ValueObject<T> {
val value: T
}
A ValueValidator
is an interface defining value validation functions. The primary validation function, of
, takes an
input and returns either a ValueFailure
or a ValueObject
. By convention, a value validator implementation is an
abstract class, because the value object's private constructor is often passed to its primary constructor as a
reference.
interface ValueValidator<I, F : ValueFailure<I>, T : ValueObject<I>> {
fun of(input: I): Either<F, T>
// ...
}
The included StringInRegexValidator
class is an example of a ValueValidator
implementation.
abstract class StringInRegexValidator<T : ValueObject<String>>(private val ctor: (String) -> T) :
ValueValidator<String, GenericValueFailure<String>, T> {
protected abstract val regex: Regex
override fun of(input: String): Either<GenericValueFailure<String>, T> {
return if (regex.matches(input)) {
Either.Right(ctor(input))
} else {
Either.Left(GenericValueFailure(input))
}
}
}
Value object classes can be inlined on the JVM. This EmailAddress
class is an example of such a ValueObject
implementation.
/**
* A W3C HTML5 email address.
*/
@JvmInline
value class EmailAddress private constructor(override val value: String) : ValueObject<String> {
companion object : EmailAddressValidator<EmailAddress>(::EmailAddress)
}
This value object can then be used to validate an email address like so:
suspend fun createUser(params: UserParamsDto) = either {
val emailAddress = EmailAddress.of(params.emailAddress).bind()
// ... validating other params ...
repositoryCreate(
UserParams(
emailAddress = emailAddress
// ... passing other validated params ...
)
).bind()
}
The Target annotation processor library makes it easy to create functionally validated models. It takes the properties of a model template interface and generates four main classes: failure, model, params, and builder.
The failure class is a sealed interface containing data classes for each value object property declared on the model
template, containing a single value, parent
, with a type of the value object validator's failure type. A required
variant of the failure interface is also generated and is assigned to failures for properties not annotated
with @External
.
sealed interface ModelRequiredFieldFailure
sealed interface ModelFieldFailure {
data class ExternalProperty(val parent: ExternalPropertyFailure) : ModelFieldFailure
data class Property(val parent: PropertyFailure) : ModelFieldFailure, ModelRequiredFieldFailure
}
The model class is the complete model and contains a validation function, of
, similar to ValueValidator
, which takes
the raw value object property types and performs cumulative validation, calling each value object's validator and
returning either a non-empty list of model field failures or a model instance.
data class Model(/* ... */) {
companion object {
fun of(/* ... */): Either<Nel<ModelFieldFailure>, Model>
}
}
The params class is the model class excluding the model template properties annotated with @External
. It is intended
to contain all the required/non-generated properties used to create a model.
data class ModelParams(/* ... */) {
companion object {
fun of(/* ... */): Either<Nel<ModelRequiredFieldFailure>, ModelParams>
}
}
The builder class is the params class with each property wrapped in an Option
and implements the Buildable
interface
by performing a zip operation on its properties. It is intended to be used to perform a partial update operation on a
model. It also contains a convenience function, only
, which delegates to the primary constructor with each parameter
defaulting to None
.
data class ModelBuilder(/* ... */) : Buildable<ModelParams> {
override fun build(): Option<ModelParams>
companion object {
fun of(/* ... */): Either<Nel<ModelRequiredFieldFailure>, ModelBuilder>
fun only(/* ... */): ModelBuilder
}
}
Fields can be added not only by declaring them on the model template interface, but also via the AddField
annotation.
Fields added with this annotation are external by default.
@AddField("id", PositiveInt::class)
@ModelTemplate("Test")
interface TestModel {
/** ... */
}
Is equivalent to:
@ModelTemplate("Test")
interface TestModel {
@External
val id: PositiveInt
/** ... */
}
The AddField
annotation is intended to only be used for composition and should not be added to the model template
interface directly. This may be enforced in the future by changing its @Target
to AnnotationTarget.ANNOTATION_CLASS
.
To use it properly, add it to a new annotation and use the newly-created annotation instead.
@AddField("id", PositiveInt::class)
annotation class HasPositiveIntId
// ...
@HasPositiveIntId
@ModelTemplate("Test")
interface TestModel {
/** ... */
}
This is also how the included HasCreated
and HasCreatedAndUpdated
annotations work internally.
@AddField(name = "created", type = Instant::class)
annotation class HasCreated
@HasUpdated
@HasCreated
annotation class HasCreatedAndUpdated
Nested models are a developing feature. A nested model property is defined just like any other property, with the type
of its model template interface. If the nested model is part of another domain and will be dynamically populated, e.g.,
by a repository, annotate it with @External
.
@ModelTemplate("Test")
interface TestModel {
/** ... */
@External
val child: TestChildModel?
}
@ModelTemplate("TestChild")
interface TestChildModel {
/** ... */
}
Nullable nested internal models are not easily updatable using the generated builder class, and are thus not
recommended. In this example, notice the absence of @External
:
@ModelTemplate("Test")
interface TestModel {
/** ... */
val child: TestChildModel?
}
@ModelTemplate("TestChild")
interface TestChildModel {
/** ... */
}
The following builder field is generated:
data class TestBuilder(
/** ... */
val child: Option<TestChildBuilder?>
)
Now, there are two conflicting use cases:
- When the intent is to update
TestChild
, butTest.child
is null. - When the intent is to create
TestChild
, butTestBuilder.child
is not buildable toTestChildParams
.
There is a consideration to update the generated field to something like:
Option<Either<TestChildBuilder?, TestChildParams>>
This would encompass the desired delete, update, and create use cases.
Define a model template interface:
@HasPositiveIntId
@HasCreatedAndUpdated
@ModelTemplate("User")
interface UserModel {
val firstName: FirstName
val lastName: LastName
val username: Username?
val emailAddress: EmailAddress
@External
val phoneNumber: UserPhoneNumberModel?
}
@HasCreatedAndUpdated
@ModelTemplate("UserPhoneNumber")
interface UserPhoneNumberModel {
val userId: PositiveInt
val number: PhoneNumber
val validated: Boolean
}
Run a build and use the generated classes:
fun createUser() = either {
repository.create(
UserParams.of(
firstName = "John",
lastName = "Doe",
username = "john.doe",
emailAddress = "john.doe@example.com",
).bind()
).bind()
}
fun greetUser(user: User) {
println("Hello, ${user.firstName.value}!")
println("Your account was created on ${user.created}.")
}
fun textUser(user: User, message: SmsTextMessage) = either {
ensureNotNull(user.phoneNumber) { NoPhoneNumber }.run {
ensure(validated) { NotValidated }
sendSms(number, message).bind()
}
}
fun updateUser(id: PositiveInt) = repository.update(
id,
UserBuilder.only(
username = Some(null)
)
)
Note that these libraries are experimental, and their APIs are subject to change.
dependencies {
implementation("io.target-kt:target-core:$targetVersion")
}
plugins {
id("com.google.devtools.ksp") version kspVersion
}
dependencies {
implementation("io.target-kt:target-core:$targetVersion")
compileOnly("io.target-kt:target-annotation:$targetVersion")
ksp("io.target-kt:target-annotation-processor:$targetVersion")
}
See the KSP docs for additional configuration details.
- Add generated
ModelRequiredField
enum and updateBuildable
interface signature. Add validation function to ModelParams takingOption
parameters for validation without an intermediate builder.