AI-translated. Read the original in KoreanEmbedded slides, videos and images remain in their original language.
A Junior Engineer Meets Clean Architecture
Software Engineer

Hello, I'm Damon, a new engineer at Buzzvil. In this post, I will describe a design problem I encountered as a junior developer and share how guidance from my colleague JD helped me improve the design.
Why good design matters
In Clean Architecture, Robert C. Martin defines good design as reducing the people, cost, and time required to develop and maintain a system. Building a system matters, but maintaining it afterward is just as important. Software requirements keep changing, and new features are added over time.

As the graph shows, software maintenance costs are comparable to—or higher than—development costs. That is how important maintenance is. Design concepts such as Clean Architecture, DDD, and SOLID can reduce the tangible costs of development and maintenance through better design.
Designing with SRP, OCP, and DIP
SOLID is a well-known set of principles for good design. I will use SRP, OCP, and DIP as examples to explain how they can help.
Here is what they mean:
- SRP (Single Responsibility Principle): A class should have only one responsibility.
- OCP (Open/Closed Principle): Software components should be open for extension but closed for modification.
- DIP (Dependency Inversion Principle): When creating dependencies, depend on things that are less likely to change rather than things that change easily.
The starting point
I was developing a unit/UI testing library and had written the following code.
- The
TestRunnerclass receives utility classes needed for testing through dependency injection. RUN_TESTis a function that executes the test block passed as an argument.- The
TestUtilclass runs before and after the test insideRUN_TEST.
TestUtils.kt
interface TestUtils {
fun runBeforeTest()
fun runAfterTest()
}
TestRunner.kt
class TestRunner(
private val testUtils : List<TestUtils>
) {
fun RUN_TEST(block : () -> Unit) {
testUtils.forEach(TestUtil::runBeforeTest)
//RUN TEST
testUtils.forEach(TestUtil::runAfterTest)
}
}
The scenario
- The client wanted a feature that displayed test execution time in the system log.
- I added the following class to meet that requirement.
ExecutionTimeSystemLogger.kt
class ExecutionTimeSystemLogger() : TestUtil {
private var startTime : Long = 0
override fun runBeforeTest() {
startTime = System.nanoTime()
}
override fun runAfterTest() {
val endTime = System.nanoTime()
val executionTIme = endTime - startTime
println("TIME: $executionTime")
}
}
This design fully satisfies the client's requirement. However, after reviewing my code, JD left the following feedback.
- It violates the Single Responsibility Principle (SRP).
- It is responsible for calculating execution time.
- It is responsible for outputting the result.
- It violates the Open/Closed Principle (OCP).
- If the client also wants output through the file system, the existing code must change.
- It violates the Dependency Inversion Principle (DIP).
TestRunnerdepends on the concrete classExecutionTimeSystemLogger.
These problems make software difficult to change. For example, what would happen if this code were in use and the client asked to write logs to a file instead of the system log? I would probably create an ExecutionTimeFileLogger. Much of that class would duplicate ExecutionTimeSystemLogger. If the time calculation or output format changed, both classes would need to be updated. As more output destinations were added beyond the system log and files, maintaining the system would become increasingly difficult.
Building flexible software that meets requirements
SOLID is one of the established ways to handle situations like this and design flexible software. With JD's guidance, I improved the code to follow these principles.
Logger.kt
interface Logger {
fun log(message: String)
}
SystemLogger.kt
class SystemLogger() : Logger {
override fun log(message: String) {
println(message)
}
}
ExecutionTimer.kt
class ExecutionTimer(
private val logger: Logger
) : TestUtil {
private var startTime : Long = 0
override fun runBeforeTest() {
startTime = System.nanoTime()
}
override fun runAfterTest() {
val endTime = System.nanoTime()
val executionTIme = endTime - startTime
val message = "TIME: $executionTIme"
logger.log(message)
}
}
This improved the design in the following ways.
ExecutionTimeSystemLoggeris separated intoExecutionTimerandLogger, each responsible for its own work.- Each class has one responsibility.
SystemLoggerimplementsLogger. If the client wants logs written to a file instead of the system log, we can simply implementFileSystemLogger.- The design is open for extension.
ExecutionTimerdepends onLogger. BecauseLoggeris unlikely to change, the likelihood of having to changeExecutionTimeras a result is also lower.- Depend on what stays stable, rather than what changes easily.
Now we can respond to the following client request by adding FileSystemLogger as shown below.
“Please add a feature to write execution times to a file.”
class FileSystemLogger() : Logger {
override fun log(message: String) {
//File System Logging..
}
}
With this design, changes to the execution-time calculation or output format do not affect the new logger. Another important benefit is that separating ExecutionTimeSystemLogger into ExecutionTimer and Logger makes Logger reusable elsewhere.
Closing thoughts
I have shared how a new engineer at Buzzvil improved code with guidance from a colleague. I will be back with another interesting post. And if you join Buzzvil as a junior engineer, many of our developers will explain in detail through code reviews how to design software more flexibly!