Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Real Coding: A Grand Redesign

DZone's Guide to

Real Coding: A Grand Redesign

What do you do when you realize that your application won't scale to tens of millions of users? Actually, nothing, unless you really need it.

· Java Zone
Free Resource

Build vs Buy a Data Quality Solution: Which is Best for You? Gain insights on a hybrid approach. Download white paper now!

Welcome to the third installment of the Real Coding series, in which I’m solving a real-world coding problem to share my knowledge and way of thinking about programming with a wider audience. The series (Part 1 and Part 2) is split by individual Pomodori of work that I perform on the project. During this particular Pomodoro, I began a grand redesign of an application’s domain. Let’s have a closer look.

What?! Why?

As some of you might recall, the application being built throughout the series is an “enterprise-grade” ranking system. Since I’m aiming for completing the whole system in around 15 Pomodori, doing one Pomodoro per day (The Pomodori days are not necessarily consecutive due to a tight schedule), I kind of rushed through the initial design phase with something like this:

class Ranking(val name: String,
              val defaultRating: Rating,
              val ratings: MutableMap<Player, Rating> = mutableMapOf(),
              val matches: MutableList<Match> = mutableListOf()) {

    fun join(player: Player) {
        ratings.put(player, defaultRating)
    }

    fun addMatch(match: Match) {
        requireRanked(match.player1)
        requireRanked(match.player2)
        matches.add(match)
    }

    private fun requireRanked(player: Player) {
        if (!ratings.containsKey(player))
            throw PlayerNotRankedException(player)
    }

    fun confirmResult(id: MatchId) {
        val match = matches.find { it.id == id } ?: throw MatchNotFoundException(id)
        match.status = MatchStatus.CONFIRMED
    }
}


My gut was telling me the whole time that this might not be the best design, but for the lack of a better alternative, I stuck with it.

A few days ago, I had a brief chance to take a look at Epic Games’ newest production: Fortnite Battle Royale. As it turns out, Epic Games’ developers are currently working on a ranking solution of their own. I conducted a super-short thought experiment: How would my design perform in a > 10 million player online game?

Well, I guess it goes without saying that it would completely fail and blow up the server each time it attempts to load a ranking. While my ranking system is more aimed at small groups, like my company’s Ping Pong players, the complete lack of scaling feels bad. “Too bad.”

Looking For a Better Design

Having decided that to (at least partially) solve the problem, I had to come up with a better design. To do that, I had to answer the question: Why exactly does the current design fail so badly?

The answer to this question is not rocket science. We’re loading all of the possible matches and ratings to the server’s memory at once, while what we need is just a couple of ratings to update (or a hundred in the battle royale case). The Ranking class in the form above is an example of a particularly bad aggregate pattern usage.

The solution to our scaling problem also lies in the answer above. Instead of loading all of the ratings, we need to load specific ones. This calls for promoting the Rating class from a mere value object to an aggregate root of its own and promoting the Match class to an aggregate root as well.

class Rating(val player: Player, // no longer a mapped VO
             val ranking: Ranking, // no longer in the context of a Ranking
             val value: Int = ranking.defaultRating) {
}

class Match(val dateTime: LocalDateTime,
            val player1: Player,
            val player2: Player,
            val ranking: Ranking, // no longer in the context of a Ranking
            var result: MatchResult,
            var status: MatchStatus = MatchStatus.WAITING_FOR_PLAYER2,
            val id: MatchId = MatchId()) {

    init {
        requireRanked(player1, ranking)
        requireRanked(player2, ranking)
    }

    private fun requireRanked(player: Player, ranking: Ranking) {
        if (!ranking.isRanked(player))
            throw PlayerNotRankedException(player)
    }
}

class Ranking(val name: String,
              val defaultRating: Int,
              val ratings: Set<Rating> = mutableSetOf()) {

    fun isRanked(player: Player) = ratings.any { it.player == player }
}


Wait, Where Did the Logic Go?

As you’ve probably noticed, the Ranking class is no longer a holder of all the use-case-related logic like joining a ranking or adding a match. As ratings and matches are now aggregates, they are to be created and managed independently of the rankings. This calls for moving the use-case logic into domain services instead of stacking it all with the ranking.

class RankingService(val ratings: RatingRepository,
                     val players: PlayerRepository,
                     val rankings: RankingRepository,
                     val matches: MatchRepository) {

    fun join(request: JoinRankingRequest) {
        val player = players.byName(request.player)!!
        val ranking = rankings.byName(request.ranking)!!

        ratings.add(Rating(player, ranking))
    }

    fun addMatch(request: AddMatchRequest) {
        val player1 = players.byName(request.player1)!!
        val player2 = players.byName(request.player2)!!
        val ranking = rankings.byName(request.ranking)!!

        matches.add(Match(
                request.dateTime,
                player1,
                player2,
                ranking,
                MatchResult.valueOf(request.result)))
    }
}

data class JoinRankingRequest(val player: String,
                              val ranking: String)

data class AddMatchRequest(val dateTime: LocalDateTime,
                           val player1: String,
                           val player2: String,
                           var result: String,
                           var ranking: String)


As you can see, there's still some stuff to do, like handling the case of an invalid request, but that's for next time. Also, so far, I’ve put everything into a single class, but there’s a high chance that each of the “use cases” will get a class of their own at one point.

As we moved the code from the domain layer to the service layer, we’re now accessing domain objects via repositories rather than via fields. Since I don’t have a clue about the data storage yet, these are just regular Kotlin interfaces.

interface RatingRepository {
    fun add(rating: Rating)
}

interface RankingRepository {
    fun byName(name: String): Ranking?
}

interface PlayerRepository {
    fun byName(name: String): Player?
}

interface MatchRepository {
    fun add(match: Match)
}


Uncle Bob Won’t Be Proud, But…

Despite my attempts, I failed in the process of performing the redesign and keeping the tests working at all times. I didn’t think the process through and I started by moving the addMatch method to the service while completely ignoring that joining is a prerequisite to adding a match. Having realized that, I decided to keep going and live with a red bar for a while. 

(I decided to not show you the non-working tests. We'll look at them once they're finished.)

A glass of water for the burning torches of you Uncle Bob fans might be that I’ve resisted the temptation to use a mocking framework and implemented in-memory repositories by myself:

class InMemoryRatingRepository : RatingRepository {
    val ratings = mutableSetOf<Rating>()

    override fun add(rating: Rating) {
        ratings.add(rating)
    }
}

class InMemoryMatchRepository : MatchRepository {
    val matches = mutableSetOf<Match>()

    override fun add(match: Match) {
        matches.add(match)
    }
}

class InMemoryPlayerRepository : PlayerRepository {
    val players = mutableSetOf<Player>()

    override fun byName(name: String) = players.find { it.name == name }
}

class InMemoryRankingRepository : RankingRepository {
    val rankings = mutableSetOf<Ranking>()

    override fun byName(name: String) = rankings.find { it.name == name }
}


Summary

There’s a lot of work behind us and still quite a lot in front. In general, I believe that the redesign will make the application a better fit for larger companies and a better example of enterprise-grade code. This project aside, a lesson learned is worth noting for anyone: a big aggregate can cause BIG problems when good scaling is required.

Build vs Buy a Data Quality Solution: Which is Best for You? Maintaining high quality data is essential for operational efficiency, meaningful analytics and good long-term customer relationships. But, when dealing with multiple sources of data, data quality becomes complex, so you need to know when you should build a custom data quality tools effort over canned solutions. Download our whitepaper for more insights into a hybrid approach.

Topics:
java ,redesign ,scalability ,enterprise app development ,aggregate ,tutorial

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}