Chapters

Hide chapters

Real-World Android by Tutorials

Second Edition · Android 12 · Kotlin 1.6+ · Android Studio Chipmunk

Section I: Developing Real World Apps

Section 1: 7 chapters
Show chapters Hide chapters

3. Domain Layer
Written by Ricardo Costeira

Having your business logic smeared throughout your app is a recipe for disaster. In time, things will get messy:

  • Code will become hard to find.
  • You’ll start reimplementing logic by accident.
  • Logic will get more and more coupled to the code that calls for it.
  • Your code will have mixed responsibilities. As the project grows, it’ll become harder to change.

That’s why it’s a good practice to decouple your business logic. A nice way to do that is to implement a domain layer.

In this chapter, you’ll learn:

  • What a domain layer is, why you need it — and when you don’t.
  • The difference between domain entities and value objects.
  • How to determine which entities and/or value objects to model.
  • Common issues in domain modeling.
  • The role of a repository in the domain layer.
  • What you should test.

Although use cases are also part of this layer, you won’t implement any for now because they’re tailored for features.

What Is a Domain Layer?

The domain layer is the central layer of your app. It includes the code that describes your domain space along with the logic that manipulates it. You’ll probably find at least the following objects in every domain layer you work with:

  • entities: Objects that model your domain space.
  • value objects: Another kind of object that models your domain space.
  • interactors/use cases: Logic to handle entities and/or value objects and produce a result.
  • repository interfaces: Define contracts for data source access.

This layer encompasses the business logic of the app. Your business logic is one of the most important parts of your app, as it defines how the app works. The less you mess with it, the better! That’s why the domain layer shouldn’t depend on other layers.

For example, imagine you change your data layer by migrating from REST to GraphQL. Or you change your presentation layer by migrating the UI to Jetpack Compose. None of those changes have anything to do with the business logic. As such, they shouldn’t affect the domain layer at all.

Do You Really Need a Domain Layer?

Whether a domain layer is necessary is a source of debate in the Android community. Some people argue that it doesn’t make sense to have one in Android apps. Google, in their architecture guide, also mark the domain layer as optional.

At a high level, a lot of Android apps follow the same simple pattern. They:

  1. Get data from a data source.
  2. Show the data in the UI.
  3. Update the data source with new data.

From a layered architecture point of view, it seems like a data and a presentation layer would be enough!

And they are — for the app to work, at least. You just need to pass data between the layers, maybe add some logic in your ViewModels to handle the data, and off to the Play Store it goes.

You have a working app, but you forgot about something — or someone — really important. You forgot about you.

Having a domain layer is a way of protecting yourself as a developer. Sure, it can seem like unnecessary, redundant work, but it pays off in the long run by:

  • Keeping your code clean and easy to maintain by focusing the business logic in one layer only, and decoupled from data source code. Single responsibility code is easier to manage.
  • Defining boundaries between code that implements app logic and code that has nothing to do with that logic, like UI or framework code. Given how fast the Android framework changes, this separation is critical.
  • Easing the onboarding of future developers, who can study the layer to understand how the app works.

If you’re working with a small codebase, it’s true that having a domain layer might be over-engineering. If all your app does is transform API data to show it to the user, then you can just use the repository for those transformations and your code will work, nice and simple. Yet, small apps are becoming increasingly rare. Even for apps that are small feature-wise, code gets really complex, really fast. It might seem like over-engineering at first, but sooner rather than later, it’ll turn out to be a life- and sanity-saving design decision.

In the end, it’s something you should consider, or discuss with your team. It might even make sense to start without a domain layer, and then add one in the future if needed.

At this point, PetSave has a relatively small codebase. As you go through the book and add more code, however, you’ll start to see how the domain layer really shines. You’ll see how nice it is to have a clear separation of concerns, which in turn allows for easily tested logic.

But that’s in the future. For now, it’s time to add your first domain entities.

Creating Your Domain Model

Use Android Studio and open the PetSave project you find in the starter folder in the material for this chapter, and expand the common.domain package. You’ll see two other packages inside:

  • model: Where all entities and value objects live.
  • repositories: Where you’ll find any repository interfaces.

You’ll come back to the repositories later. For now, focus on the model package. As you start exploring it, you’ll notice that it already has quite a few files inside.

Figure 3.1 — Domain Package Structure
Figure 3.1 — Domain Package Structure

Entities and Value Objects

Now’s a good time to establish the difference between entities and value objects. Expand the common.domain.model.organization package and open Organization.kt. Focus on the first data class:

data class Organization(
    val id: String,
    val contact: Contact,
    val distance: Float
)

This class represents the organization entity. It has an id that identifies it and a few properties that describe it. Look at that Contact, and you’ll notice that it doesn’t have an ID.

data class Contact(
    val email: String,
    val phone: String,
    val address: Address
)

This is what distinguishes entities from value objects:

  • Entities have an ID that allows you to tell them apart. Their properties can change, but the ID always remains the same.
  • Value objects describe some aspect of an entity. They don’t have IDs, and if you change one of their properties, you create a new value object. For this reason, they should always be immutable.

Note: The concept of entities and value objects comes from Domain Driven Design. Although the distinction between them goes deeper, the key thing to remember is this: Identity is important for entities.

As long as the ID remains the same, entities’ properties can change. However, the Organization entity only has immutable properties.

That’s because it’s good practice to favor immutable objects until you need to make them mutable. This does wonders for avoiding bugs that stem from mutable objects. A very common example is mutable object handling with asynchronous code.

What Should You Model?

In a real-world situation, you wouldn’t have to think about what to model at this point. You would have info from stakeholders and project owners and decisions from meetings that would guide you on what to implement.

Frequently, apps are built to support a pre-existing business. In these cases, the domain model already exists somewhere — typically in the back end. Therefore, reproducing the back end’s domain model is usually enough.

This last option is actually the case for PetSave. Most of the domain is based on the petfinder API. And why not? They already have a working back end with a matching front end. Plus, it’s the only API PetSave will use in the foreseeable future.

More often than not, the domain model entities end up being manifestations of the domain’s names. For PetSave, you know the domain has to do with animal adoption and care. You adopt animals from organizations that care for them. Animals and organizations seem like a starting point!

Adding the Animal Entities

As you’ve seen, Organization already exists. Next, you’ll add the Animal entities.

Expand the animal package, then expand the details package inside it. Every file you see inside the animal package is a value object. Each data class represents a collection of related attributes, while each enum represents a closed attribute set. They are simple objects, but a few are worth checking out. You’ll get to them in a few minutes.

For now, you need to add the Animal entity.

Create a new Animal.kt file in the animal package. In it, add the Animal class:

data class Animal(
    val id: Long,
    val name: String,
    val type: String,
    val media: Media,
    val tags: List<String>,
    val adoptionStatus: AdoptionStatus,
    val publishedAt: LocalDateTime
)

Don’t forget to import LocalDateTime from the java.time library. Gradle will handle the desugaring for you.

This entity is fairly simple. It has a few primitive properties, a LocalDateTime for the publishing date and two value objects:

  • media: A Media value object instance that handles photos and videos of the animal.
  • adoptionStatus: An enum value from AdoptionStatus.

adoptionStatus can be one of four values:

enum class AdoptionStatus {
  UNKNOWN,
  ADOPTABLE,
  ADOPTED,
  FOUND
}

There’s not much to see here, just a simple enum. Open Media and take a look at how it’s implemented. You can see that it has two properties:

  • photos: A list of Photo objects.
  • videos: A list of Video objects.

Both Photo and Video classes are nested in Media for ease of access.

Now, take a closer look at Photo:

data class Photo(
    val medium: String,
    val full: String
) {

  companion object {
    const val NO_SIZE_AVAILABLE = ""
  }

  fun getSmallestAvailablePhoto(): String {  // 1
    return when {
      isValidPhoto(medium) -> medium
      isValidPhoto(full) -> full
      else -> NO_SIZE_AVAILABLE
    }
  }

  private fun isValidPhoto(photo: String): Boolean { // 2
    return photo.isNotEmpty()
  }
}

It’s a value object with two properties:

  • medium: A link for the medium-sized photo.
  • full: A link for the full-sized photo.

There’s also some logic in it:

  1. Returns the smallest-sized photo available, which will be useful to display the animal images in the animal list. You don’t need high-resolution images for a list and the smaller the image, the fewer bytes to request from the API.

  2. Checks if the photo link is valid. For simplicity, it just checks if the link is not an empty string.

This is good! When you have a piece of logic related to a domain model object, it’s a good practice to keep that logic contained within the object.

Remember the concept of high cohesion? This is a good example of it. The logic has a close relationship with the object, to a point where it ends up using all the object’s properties. This means that you’re not tightly coupling Photo to something else.

Another important thing to mention in Photo is its companion object — more specifically, NO_SIZE_AVAILABLE. This property represents the empty state of a Photo. It’s a simplified version of the Null Object Pattern, and it’s a nice way to avoid null values.

Yes, you could simply just return an empty string in getSmallestAvailablePhoto(), like so:

fun getSmallestAvailablePhoto(): String {
    return when {
      isValidPhoto(medium) -> medium
      isValidPhoto(full) -> full
      else -> ""
    }
  }

But that’s not the point. Since it’s such a simple example, NO_SIZE_AVAILABLE just happens to be an empty string. Don’t look at the values the code is handling; instead, look at its intent. You shouldn’t care about NO_SIZE_AVAILABLE being an empty string — the important thing here is that NO_SIZE_AVAILABLE tells you that a Photo has no sizes available.

Zoom out to Media and you’ll see that it follows the same approach. It has:

  • Highly cohesive logic.
  • A simplified Null Object Pattern with NO_MEDIA.

Note: For simplicity, this code ignores Video. In a more complex example, it would follow the same approach as Photo, but with logic for video handling.

AnimalWithDetails Entity

The Animal entity is enough for this section’s features. However, there will be a details screen later that will need more details than Animal provides. So you might as well add that functionality now.

In the details package, create a new file, AnimalWithDetails.kt. In it, add AnimalWithDetails:

data class AnimalWithDetails(
    val id: Long,
    val name: String,
    val type: String,
    val details: Details,
    val media: Media,
    val tags: List<String>,
    val adoptionStatus: AdoptionStatus,
    val publishedAt: LocalDateTime
)

This entity is exactly the same as Animal, but it has an extra details property. You might wonder why you don’t just add a nullable details property to the Animal entity. Well, you could. This is just a design choice for the sake of avoiding nullable values. It would be totally OK to go with the nullable property option.

The Details value object uses the remaining value objects in the packages, along with the Organization entity.

data class Details(
    val description: String,
    val age: Age,
    val species: String,
    val breed: Breed,
    val colors: Colors,
    val gender: Gender,
    val size: Size,
    val coat: Coat,
    val healthDetails: HealthDetails,
    val habitatAdaptation: HabitatAdaptation,
    val organization: Organization
)

Nothing’s new here except the Breed data class. Open it, there’s an interesting detail here that you should be aware of. This is the data class:

data class Breed(val primary: String, val secondary: String) {
  val mixed: Boolean
    get() = primary.isNotEmpty() && secondary.isNotEmpty()

  val unknown: Boolean
    get() = primary.isEmpty() && secondary.isEmpty()
}

And this is an example of what the API returns regarding breeds:

"breeds": {
  "primary": "Golden Retriever",
  "secondary": null,
  "mixed": false,
  "unknown": false
}

The first obvious change is that secondary goes from a nullable String to a non-nullable one. You’ll explore this kind of mapping in the next chapter, so don’t bother with it for now. Apart from this, notice any differences in how information is being passed?

Take a closer look at the properties to understand what they are:

  • primary: The primary breed.
  • secondary: The secondary breed.
  • mixed: Tells you if the animal has mixed breeds — it has both a primary and a secondary breed.
  • unknown: Tells you if the animal’s breed is unknown — it has neither a primary nor a secondary breed.

Only the first two properties — primary and secondary — are a part of Breed’s constructor. The other properties, mixed and unknown, are deduced from the first two.

Note: Since Breed is a data class, it has a few auto-generated methods. Be aware that, in this case, both mixed and unknown are not accounted for by those methods, as they’re outside the constructor.

In all fairness, it’s quite possible that the API also deduces mixed and unknown from the other two. It returns them all as independent properties because it has no other option. While it’s true that mixed and unknown only add noise, it’s a totally valid way of building a back end: When in doubt, return everything you have. :]

By having some properties depend on others, you increase the cohesion of the class. This increases the amount of information conveyed when you read the class. Also, if you create a copy of the class but change one of the constructor values, both mixed and unknown will update. Talk about a good deal!

Adding the PaginatedAnimals Value Object

The previous chapter talks about the API returning chunks of paginated data. The pagination information is also relevant to the UI, letting RecyclerView request the correct data chunk. It’s a data layer implementation detail, but it ends up leaking to the presentation layer.

So, why not model this information as well? Yes, it doesn’t exactly fit the domain. Still, it’s better to have it modeled and maintain the boundaries between layers than to break the dependency rule even once.

Try not to cut corners on this kind of decision. Otherwise, you’ll start to notice broken windows in your app.

Note: The broken window theory is a theory that describes software decay. It states that visible signs of crime create an environment that fosters more crime. In other words, as soon as you start cutting corners in your app, you’ll do it more and more often.

Expand the pagination package next to the animal and organization packages. Inside, you’ll find there’s already a Pagination value object. This is the generic representation of the API’s pagination. You’ll now add the specific animal pagination.

Inside the pagination package, create a new file called PaginatedAnimals.kt. Add the following class to the file:

data class PaginatedAnimals(
    val animals: List<AnimalWithDetails>,
    val pagination: Pagination
)

This value object associates a list of animals with a specific page. It’s exactly what the UI needs to know which page to request next.

You added two entities, a value object, and learned some of the intricacies of domain modeling. Well done! Before diving into repositories, there are still a few domain modeling topics worth addressing.

To Type or Not to Type

Look at Animal again:

data class Animal(
    val id: Long,
    val name: String,
    val type: String,
    val media: Media,
    val tags: List<String>,
    val adoptionStatus: AdoptionStatus,
    val publishedAt: LocalDateTime
)

If you exclude the value objects and the publishedAt property, you’re left with:

data class Animal(
    val id: Long,
    val name: String,
    val type: String,
    val tags: List<String>
)

These properties all have one thing in common: None of them have specific domain types. In fact, they’re just a mix of standard types from the language.

When modeling your domain, you need to make some choices, and those choices have trade-offs. One of the hardest choices to make is how many new domain-specific types you should create.

Types provide safety and robustness in exchange for complexity and development time. For instance, what’s keeping you from creating Animal with the id of -1L? It’s just a Long type. It doesn’t care about the value you set it to, as long as it’s of type Long.

However, adding a new type called Id changes things:

@JvmInline
value class Id(val value: Long) { // 1
  init { // 2
    validate(value)
  }

  private fun validate(id: Long) {
    if (id.hasInvalidValue()) { // 3
      throw InvalidIdException(id)
    }
  }
}

Here are some things to note in this code:

  1. Kotlin’s value classes are a neat way to wrap your primitives into custom types, since they (mostly) spare you from the overhead of handling a normal class.
  2. init blocks run immediately after the primary constructor, so this calls validate as soon as you create an instance of Id.
  3. hasInvalidValue is an extension function on Long that verifies whether the ID value is -1L or 0. If so, validate will throw an InvalidIdException.

Now, imagine that Id has a specific format. Then, you need to add a new validation:

private fun validate(id: Long) {
  if (id.hasInvalidValue()) {
    throw InvalidIdException(id)
  }

  if (id.hasInvalidFormat()) {
    throw InvalidIdFormatException(id)
  }
}

Suppose that the formatting spec determines the size limit of the ID. It’s a specific case of format validation that deserves its own validation for clarity. By updating the code:

private fun validate(id: Long) {
  when {
    id.hasInvalidValue() -> throw InvalidIdException(id)
    id.hasInvalidFormat() -> throw InvalidIdFormatException(id)
    id.exceedsLength() -> throw InvalidIdLengthException(id)
  }
}

You also change from a chain of if conditions to a when.

It looks clean, but it now throws a bunch of exceptions. You start worrying that it might be hard to maintain the code in the future, especially if you add new validation rule. So, you refactor the whole thing:

@JvmInline
value class Id private constructor(val value: Long) { // 1
  companion object {
    fun of(id: Long): Either<IdException, Id> { // 2
      return when {
        id.hasInvalidValue() -> Either.Left(IdException.InvalidIdException(id))
        id.hasInvalidFormat() -> Either.Left(IdException.InvalidIdFormatException(id))
        id.exceedsLength() -> Either.Left(IdException.InvalidIdLengthException(id))
        else -> Either.Right(Id(value = id))
      }
    }
  }

  sealed class IdException(message: String): Exception(message) { // 3
    data class InvalidIdException(val id: Long): IdException("$id")
    data class InvalidIdFormatException(val id: Long): IdException("$id")
    data class InvalidIdLengthException(val id: Long): IdException("$id")
  }
}

sealed class Either<out A, out B> { // 4
    class Left<A>(val value: A): Either<A, Nothing>()
    class Right<B>(val value: B): Either<Nothing, B>()
}

Here’s what’s happening, step by step:

  1. You make the constructor private so that you can fully control new Id instance creation.
  2. The old validate function is now called of, and works as a factory function of Id’s companion object. With the constructor being private, you’re now forced to do Id.of(123L) if you want to create an instance of Id. You also change the functions’s signature to be explicit about what’s happening inside.
  3. You encapsulate all the exceptions in the IdException sealed class.
  4. You create the Either sealed class, a disjoint union to represent success and failure values.

As you can see, it’s pretty easy to get carried away.

Dealing with Booleans is also fun. For instance, consider this class:

class User(name: String, email: String, isAdmin: Boolean)

You can see where this is going, can’t you? That isAdmin is a disaster waiting for the worst moment possible to explode in your face. A simple mistake or a bug that makes the property true when it should be false can completely wreck your app.

A common way to avoid stuff like this is to use inheritance:

open class User(name: String, email: String)

class Admin(name: String, email: String) : User(name, email)

Congratulations! You now have one extra class to maintain, and possible inheritance issues that might come from it. You have to agree though, that the code is a lot safer this way.

There’s usually some uncertainty over whether all the extra work will pay off in the future or not. For instance, in the Id example: Is all of that needed? Maybe some of those cases that you took measures against would never happen anyway. You’d be maintaining all that complexity for nothing!

It’s up to you and your team to decide. Do you want to follow a straightforward, “we’ll refactor when we get there”, YAGNI (You Aren’t Gonna Need It) approach? Or a more time-consuming, type-safe, “model all the things!” way of working?

In general, a solution somewhere in the middle, with just enough design upfront, will fit your needs the best.

Anemic Model

Managing types is not the only common problem in domain models. Sometimes, domain models can become anemic, which means that they mainly consist of data without behavior.

On Android, it’s common for the domain layer to work mainly as a bridge between the other layers. In fact, this is one of the most common arguments against having a domain layer on Android.

PetSave is an example: Other than Breed, Media and Photo, no other domain class has any kind of logic in it. PetSave has what seems like a few anemic models.

However, note that your domain is only starting to take shape. The app is at an early stage, so it’s normal that you don’t have enough domain knowledge to add logic to the models.

It’s possible for the app to grow and its domain to remain anemic. But even so, it’s good to weigh the advantages of having a domain layer on an ever-changing ecosystem like Android before deciding to completely remove it.

This wraps up the domain modeling topics. When you implement use cases later, you’ll need them to access data sources. You’ll have to do it without forcing a dependency on the data layer, to preserve the dependency rule. This is where repositories come in handy.

Inverting Dependencies With Repositories

A repository is a very common pattern for data source abstraction. You use it to abstract away all the data sources you want. Anything that calls the repository can access data from those sources, but will never know which sources even exist.

In the domain layer, you won’t actually implement a repository. Instead, you’ll only have a repository interface. This allows you to invert the dependency on the layers, making the data layer depend on the domain layer, instead of the other way around!

How? it’s simple, and you can start putting it into place right away. In the repositories package, create AnimalRepository.kt. In it, add your interface:

interface AnimalRepository

Later, you’ll implement an actual repository class in the data layer. That class will implement this interface and any of its methods. Then, any time a use case needs data access, you’ll pass it that repository class as a dependency, but the dependency’s type will match the interface’s.

This way, use cases can access all the methods in the interface’s contract, without ever knowing the class that fulfills it. The use case does its job and preserves the dependency rule. Win-win!

For now, you’ll leave the interface just like this. It might be anticlimactic, but it’s much easier to add methods later, when you’re developing the features and know exactly what data you need.

That’s it for the app code for this chapter. For your work as a developer to be complete, though, you’re still one thing missing: tests!

Testing Your Domain Logic

When you build the project, there won’t be any UI changes to let you know that your code works. Still, at least you can rely on tests to tell you that your code does what you expect.

You’ll definitely do some heavy testing later, when you implement use cases. For now, though, there’s not that much to test. Regardless, you want to make sure you start testing as soon as possible.

Your next step is to add tests to verify the domain logic you saw earlier. Adding tests to every class would be redundant for your purposes, so you’ll focus on unit tests for Photo.

Collapse the petsave package, the root of the project. You’ll see three main packages.

Figure 3.2 — Android Project Build Types
Figure 3.2 — Android Project Build Types

Expand the package that has the (test) label in front of it. This is where you’ll add your tests, since it’s the place where tests that don’t rely on the Android framework should live.

A good way to organize your tests is to mimic the package structure of the app code. This makes it possible for tests to access any internal properties of the code, since anything with the internal visibility modifier is only accessible to code in the same package.

At the root of the package, create the following structure: common/domain/model/animal. Inside animal, create PhotoTests.kt. You’ll end up with something like this:

Figure 3.3 — Testing Source Structure
Figure 3.3 — Testing Source Structure

Test dependencies are already taken care of. Open the file you just created and add the class along with a test:

class PhotoTests {

  private val mediumPhoto = "mediumPhoto"
  private val fullPhoto = "fullPhoto"
  private val invalidPhoto = "" // what’s tested in Photo.isValidPhoto()

  @Test
  fun photo_getSmallestAvailablePhoto_hasMediumPhoto() {
      // Given
      val photo = Media.Photo(mediumPhoto, fullPhoto)
      val expectedValue = mediumPhoto

      // When
      val smallestPhoto = photo.getSmallestAvailablePhoto()

      // Then
      assertEquals(smallestPhoto, expectedValue)
  }
}

This test verifies the happy path, with the photo at its smallest available resolution. This Given – When – Then structure is a nice way of organizing your test code. If you maintain these comments, it gets easier to maintain the actual code in the future.

It’s true: The words “maintenance” and “comments” don’t mix. Still, you should keep your tests small and focus on one thing at a time. If you do, it won’t be too hard to keep the comments in place.

Go ahead and add more tests below this one:

@Test
fun photo_getSmallestAvailablePhoto_noMediumPhoto_hasFullPhoto() {
    // Given
    val photo = Media.Photo(invalidPhoto, fullPhoto)
    val expectedValue = fullPhoto

    // When
    val smallestPhoto = photo.getSmallestAvailablePhoto()

    // Then
    assertEquals(smallestPhoto, expectedValue)
}

@Test
fun photo_getSmallestAvailablePhoto_noPhotos() {
    // Given
    val photo = Media.Photo(invalidPhoto, invalidPhoto)
    val expectedValue = Media.Photo.NO_SIZE_AVAILABLE

    // When
    val smallestPhoto = photo.getSmallestAvailablePhoto()

    // Then
    assertEquals(smallestPhoto, expectedValue)
}

The first test checks if you’re returning the larger photo, in case the medium one is invalid. The second test checks if you’re returning NO_SIZE_AVAILABLE when both photo sizes are invalid. The only missing test now is for the case when you have a medium photo, but not a full photo. No point in adding it though, as it would be similar to the first test you just added.

These tests are simple. And they should be! They’re unit tests, after all. The important thing here is that they’re actually testing behavior.

Take the last test, for example. You’re initializing Photo with invalidPhoto, and the expectedValue is NO_SIZE_AVAILABLE. You know that both are empty strings, so why not use the same property everywhere?

As discussed earlier, that’s not the behavior the code wants to achieve. They just happened to both be empty strings — their meanings are vastly different. This is what you have to test: You should test behavior, not code or data.

Key Points

  • Domain layers protect you, the developer, and the app’s logic from external changes.
  • Entities have an identity that allows you to distinguish between them.
  • Value objects enrich your domain and can either contain entities or be contained by them.
  • Defining how many custom types to have in your domain is something you should consider carefully. Try to find a balance between under typing and over typing, as both are troublesome.
  • Be careful when adding logic to your app that relates to your domain model: That logic might belong inside the actual model classes.
  • Repository interfaces allow for dependency inversion, which is essential to keep the domain layer isolated.
  • Test behavior, not code or data.

You’ve reached the end of the chapter. Awesome! Next, you’ll learn about the data layer.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.