Damian Garstecki
4 min read

Cost of layered architecture

Layered architecture is one of the most popular choices in object‑oriented systems, and for good reason. When done right, it provides great separation of concerns, making each layer replaceable without the need to transform everything around it. But layers require a lot of additional models. In this post, we take a look at how additional models and constant mappings impact the performance of our app.

Glance at the Benchmark app

Most common uses of layered architecture define four layers: UI, Application, Domain, and Persistence. To keep better separation, each layer is supposed to have its own data model that’s used only inside that layer. This means we’re creating many versions of very similar classes just to satisfy each layer’s needs.

This post is about benchmarking an example JVM app. We’ll look at speed and memory usage with and without layers. You can find the source code here. Feel free to run it and see the outcome on your machine.

Our benchmark tool is a Kotlin app with a single dependency on com.neovisionaries:nv-i18n (used for CountryCode). The premise is simple. We create a User with some basic properties. The user is stored in an in‑memory repository. After that, we retrieve the data and return it to the caller.

internal data class User(
    val id: UUID,
    val name: String,
    val lastName: String,
    val birth: Date,
    val email: String,
    val address: Address
)

internal data class Address(
    val street: String,
    val postCode: String,
    val city: String,
    val country: CountryCode
)

We execute the same flow in two different ways:

  1. Using all the layers: UI, Application, Domain, and Persistence — handleThroughoutAllLayers()
  2. Skipping most of the layers — handleSkippingLayers()

Using all layers we map our model 6 times: UserRequest → UserDto → User → UserDao → User → UserDto → UserResponse

Skipping layers simplifies the flow a lot: UserRequest → UserDao → UserResponse. We map objects only 2 times—four fewer mappings.

With that out of the way, we can look at the actual benchmark numbers.

All the tests were made on MacBook Pro with Intel I7-9750H and 16GB of RAM. Benchmark app was running on OpenJDK 18.0.2. Numbers are an average from 10 consecutive runs.

Let’s take a look at the numbers!

No. of elements — number of objects processed through the whole flow. In a web app, this is comparable to the number of requests.

From the graph, time grows roughly linearly with the number of requests. On average, execution with all layers is 40–50% slower.

Looking at memory usage, the differences are even clearer. With layers, the app consumes about 80–90% more memory.

To no one’s surprise, additional layers add overhead and consume more memory. But this isn’t the whole story. We’re missing one important factor: JIT. The JVM can improve performance at runtime. We just need to give it a little time to figure out optimizations. Let’s see how performance changes if we give the JVM some time to warm up.

For the warm-up tests, I will execute the same query N times before start measuring the execution time.

for (i in 0..30) { // warm-up
    userRequests.forEach { controller.handleThroughoutAllLayers(it) }
}

val start = System.currentTimeMillis()

userRequests.forEach { controller.handleThroughoutAllLayers(it) }

val end = System.currentTimeMillis()

println("All layers execution of $batch - ${end - start}ms")

By increasing the number of warm‑up runs, we decrease the difference between both approaches. JIT slowly finds ways to reduce time spent on mapping objects, and after ~20 runs both methods converge to a similar outcome.

A quick look at memory usage shows JIT does not help here; the discrepancy between methods stays at the same level.

Conclusion

Performance discrepancies normalize after a few runs and get close to zero. The larger memory footprint remains the main difference. It’s possible that tinkering with GC settings or using a different GC could yield better results.

You may ask yourself how big of a problem this is. There are two performance‑related downsides to layered architecture:

Slower “boot time”

Since JIT needs a few runs to reach peak speed, this might be a problem when your app runs in the cloud and you’re constantly shuffling instances. Then you might not get the full benefit from JIT. But if that’s happening, you probably have a bigger problem with your app.

Higher memory footprint

This might be an issue if RAM is limited—you’re running on a tiny server down in the basement. But again, this usually points to other problems with your infrastructure. RAM is relatively cheap, and basing architecture decisions on RAM cost alone might not be the right trade‑off.

architectureperformancejava