In software development, it's common to hear refrains like, "Another requirement change..." or "Which part of this feature needs fixing?" The business landscape constantly shifts, and new technologies emerge rapidly. A system, once built, "grows" like a living organism, increasing in complexity.
In this dynamic world of "growth," how can we maintain code health, avoid slowing down development, and ensure long-term maintainability?
The answer begins with the question: What constitutes "good code"? It then delves into a universal classification of code and a strategy for gradually nurturing your code without rushing the process.
What is "Good Code"?
While the definition of "good code" might vary from person to person, let's focus on three key points that truly help developers, especially from the perspective of "continuously evolving software."
- Changeable: This is code you can quickly and accurately modify when you need to. Most importantly, it's about localizing the impact of changes to minimize unintended side effects on other parts of the system. When this is achieved, you can confidently make changes, and development speed significantly increases.
- Understandable: This is code that a new development team member can quickly grasp without scratching their head wondering, "What is this code doing...?" This is greatly improved when code is clearly assigned roles based on its "concerns." Organized code is always easier to read than a messy tangle.
- Testable: This is code that allows for efficient and thorough testing to ensure quality. The key to this is that the code's external dependencies like databases or external services can be easily "replaced." If you can substitute these with dummy objects (mocks or stubs), you can run fast and stable tests without needing to spin up actual services.
While it's easy to get caught up in immediate feature implementation, being mindful of these "good code" characteristics truly helps development teams manage complex systems over the long term. So, how can we write such good code?
That's where the "4 universal code classifications" and the strategy for applying them gradually come in handy.
The "4 Universal Classifications" of Software: Organizing Code to Embrace Change
A software codebase can be broadly categorized into four "classifications" based on its role and likelihood of change. These are better understood as segregations based on the code's "concerns" or "areas of responsibility," rather than strict hierarchical "layers." These classifications gradually become clearer as the system grows.
1. Core: The Essence of Business, a Hub of Unchanging Value
This is the heart of the system. It contains the most crucial elements of your business: pure business rules, domain concepts, and specific use cases (what users want to achieve with the system). It's no exaggeration to say that your company's true value is encapsulated within this classification.
It's important to note that the Core classification doesn't necessarily refer to a single, strict layer. Think of it as a collection of concerns related to the essence of the business, potentially encompassing multiple internal layers like the "domain layer" or "use case layer" in clean architecture. As the system grows in size and complexity, the Core classification itself may further subdivide based on change frequency and responsibility.
Core is isolated from volatile "externals" like user interfaces, databases, and external services, allowing it to focus purely on the business essence.
Important consideration regarding Core's knowledge scope:
While Core should focus on "pure business rules," it is permissible for it to include minimal knowledge regarding the "usage" of general-purpose Utility libraries necessary to implement those rules. For example, calling methods from a general date library for date calculations. This is because Utilities are typically stable and undergo few changes. Defining abstract interfaces and adapters for such stable, general-purpose functions within the Core itself can unnecessarily increase complexity and lead to over-engineering in practice, and is often avoided.
2. Drivers: Diverse Entry Points to Operate the System
This code group acts as the "system's gateway," accepting "inputs" from users or other external systems (web requests, command-line commands, event notifications, etc.), transforming them into a format Core can understand, and invoking Core classification use cases. This corresponds to primary adapters in hexagonal architecture or the presentation layer in layered architecture.
Examples of libraries used in the Drivers classification:
UI frameworks, CLI parsers, and DI (Dependency Injection) libraries are typically used in this classification to construct the Driver and manage its connection to the Core.
3. Integrations: Safe Bridging to External Systems
This code group is responsible for the concrete interactions with external systems that the Core classification "wishes to utilize" (databases, external web services, file systems, etc.). All technical details for when the system "outputs data" or "uses external information" are encapsulated here. This corresponds to secondary adapters in hexagonal architecture or the infrastructure layer in layered architecture.
The Core classification depends only on the abstract Interface (e.g., repository contracts for saving user information) that this Integrations classification implements.
This classification includes libraries with external dependencies for communicating with external systems (e.g., database clients, payment API clients, cloud storage SDKs). Integrations also play the role of "bridging" by performing necessary data transformations (mapping) between Core's domain models, these external library interfaces, and the data formats of external systems.
4. Utilities: A Hub for General-Purpose Common Functions
This code group provides general-purpose auxiliary functions that are not directly related to specific business logic or external integrations but are used repeatedly throughout the application. This includes date calculations, data format conversions, common input validations, and time management.
Code in this classification should have no external dependencies and be highly stable. Concentrating general-purpose functions here prevents code duplication.
Caution:
It's not a case of "just throw anything commonly used into Utilities." This classification should contain purely general-purpose processes not tied to a specific domain (business area). If domain-specific knowledge or business rules creep into Utilities, it's no longer a true utility. For example, a process like "calculating the end of the month for a member's registration date" might seem general, but it includes "member" domain information. Placing such a process in Utilities could cause domain changes to ripple into Utilities, ultimately harming changeability. Selectively place code in Utilities that "produces the same result regardless of who uses it and contains absolutely no domain knowledge."
Code Growth Strategy by Phase: A Patient, Incremental Approach
"Okay, from today, I'm going to perfectly divide all my code into these 4 classifications!" While the enthusiasm is understandable, hold on a moment. Aiming for perfect architecture from the outset can lead to unnecessary complexity and often results in over-engineering for future requirements that haven't even materialized yet.
For instance, if you spend months building a complex architecture when you're still at the MVP (Minimum Viable Product) stage, everything might become obsolete if market needs change. This is the trap of "over-engineering"—designing too much before building features.
A more practical and effective approach is to gradually clarify these classifications and refine your code as the project grows. It's like nurturing a newborn system, evolving it bit by bit as needed. This aligns very well with agile development processes.
Phase 1: Start Simple – "Just Get It Working!"
State: The system is in its simplest form, focused on quickly achieving specific functionalities. This might involve a single main function or a single file containing tightly coupled user input processing, simple business logic, and direct database operations.
Trigger for this phase transition:
- "I just want to build something quickly and test an idea!"
- Rapidly launching a prototype or MVP to the market to gather user feedback.
- The project is just starting, and requirements are still fluid.
Convergence to good code: At this stage, focus on "getting something working" without overthinking future major changes or complex structures. Simpler means faster initial changes.
Phase 2: Core Separation – "Time to Organize"
State: As business logic complexity increases, you clearly separate the Core classification, the heart of the system. Drivers begin to depend on Core, and the "concerns" of the user interface and business logic start to diverge.
Trigger for this phase transition:
- "
mainfunction has grown too large, and it's hard to tell what's where..." - "I want to use the same business logic from both the web screen and a batch process, but the code is duplicating."
- "I want to write tests for the business logic, but preparing the UI makes testing difficult."
Convergence to good code:
- Changeable: Prevents UI (Drivers) changes from unnecessarily affecting Core. This makes it easier to modify or add UI elements without worrying about impacting business logic.
- Understandable: With business logic and UI concerns clearly separated, it becomes easier to understand what each part of the code is responsible for.
- Testable: Core classification tests can now be performed independently of Drivers, significantly improving their testability. Moreover, the same Core logic can be safely reused from different Drivers, such as web APIs or command lines.
This marks the beginning of separation akin to the fundamental ideas of layered architecture.
Phase 3: Integrations Separation – "Drawing External Boundaries"
State: Once integration with databases and external services becomes serious, you introduce the Integrations classification, stopping Core from directly depending on external technologies. Core evolves to define abstract Interfaces (contracts), which Integrations then "implement."
Trigger for this phase transition:
- "Database access and external API calls are mixed into the Core business logic, making it hard to read and test..."
- "Maybe we'll change the database type in the future, or add more external services."
- "It's painful to modify Core logic every time an external service's specification changes!"
Convergence to good code:
- Changeable: Even if database changes or external API specification changes occur, only the Integrations classification code needs to be modified, and the impact of changes is completely localized within the Integrations classification. This maximizes the changeability of the Integrations classification itself.
- Understandable: Technical details of external system integration are centralized in Integrations, making Core's business logic purer and easier to read.
- Testable: Since Core becomes independent of external technical details, Core classification testability dramatically improves by easily swapping in mocks for the Integrations classification. This enables fast and stable test execution by replacing external dependencies.
This is the application of the Dependency Inversion Principle (DIP) and is central to hexagonal and clean architectures.
Phase 4: Utilities Independence – "Consolidating Common Components"
State: When general-purpose auxiliary functions are widely used throughout the application, the Utilities classification becomes independent. It serves as a hub of common, useful functions depended upon by all other classifications.
Trigger for this phase transition:
- "I'm writing the same date conversion code everywhere..."
- "Common validation logic is scattered all over the place, making it hard to modify."
- "I fixed a general-purpose process, and a bug appeared in an unexpected place..."
Convergence to good code:
- Changeable: General-purpose functions are independent, making the impact of their changes on other classifications clear and easier to manage.
- Understandable: Consolidating common components into Utilities reduces code duplication, making the overall codebase organized, easier to understand, and maintainable.
- Testable: Since only code with no external dependencies and high stability is extracted here, it requires infrequent changes once created, contributing to overall changeability and making unit testing extremely easy.
Phase 5: Core Internal Deepening – "Optimization for Change"
State: As the business logic within the Core classification becomes even more complex, and there are differences in change frequency even within Core itself, you subdivide the Core internally into gradient layers based on business change frequency, loosely coupling each with Interfaces. These layers are distinguished by change frequency, such as Volatile logic (frequently changing logic), Medium volatility logic (moderately changing logic), and Stable logic (infrequently changing stable logic).
Trigger for this phase transition:
- "Frequent modifications to specific logic within Core are affecting other stable parts of Core..."
- "Business logic is too complex; it's hard to distinguish what's essential from what's frequently changing."
- "The change frequency of this business logic is clearly different from other parts."
Convergence to good code:
- Changeable: This is the ultimate pursuit of changeability. Only the most frequently changing parts of business requirements are separated, and their impact is localized to the smallest units within the Core classification. The number of these gradient layers should be flexibly adjusted based on system complexity and business change frequency.
- Understandable: Clearer roles and change frequencies within Core allow for a more detailed understanding of which parts of the codebase are core to the business and which are prone to fluctuation.
- Testable: Loosely coupled layers further refine the scope of unit tests, significantly improving testability. This shortens debugging and feature addition cycles.
This enables an extremely agile system that can perfectly keep pace with the speed of business.
Conclusion: Designing with the Future in Mind
Instead of striving for perfect "good code" from the outset, clarify and refine your "code classifications" little by little as your project grows. This "4 universal classifications" and the "code growth strategy" for applying them incrementally will serve as a powerful compass for your system to embrace change and grow sustainably.
This approach is not just about blindly applying specific architectural patterns (layered, hexagonal, clean, etc.). Rather, it's about intelligently and flexibly applying the underlying design principles of "separation of concerns," "dependency inversion," and "localization of change impact" to suit your project's phase and needs.
Starting today, try to be mindful of these classifications and growth strategies in your project. A perfect first step isn't necessary. Begin by asking yourself, "What is the role of this code?" or "If I change this, where might it have an impact?"
We hope these guidelines prove useful in your software development journey.





Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.