In software development, we have a bunch of cool techniques that help us keep complexity under control: encapsulation, abstraction, modules, interfaces, etc.
Have you ever thought about why we need them? They are not needed for computers; they are needed for us—humans. Our limit is 3–5 independent chunks of information at a time.
The famous number “7 ± 2,” proposed by George A. Miller in 1956, is still widely quoted. However, modern cognitive psychology considers it an overestimate for most reasoning tasks.
How do we fight quickly growing complexity in the age of LLMs?
Probably the first question we should answer is: should we fight it at all?
I have seen that some companies don't care what agents do; they care only about results. Here, I am considering only non-critical areas: no aviation, medical, or nuclear power plant software. You might ask: why not? LLMs make development fast; the cost per unit of LLM work is going down. But you might notice that even though the cost per unit is going down, overall costs are growing. From my point of view, not all companies can outsource code development entirely to LLMs.
I should also clarify that some complexity is unavoidable. What we should avoid is unnecessary complexity.
If you are convinced that we still need to own our code, let's see what we can do to understand and manage it.
Keeping complexity within human limits
Even before LLMs, creating code was not the main problem. Maintenance was. Without proper design, a codebase quickly becomes unmaintainable. I think you've felt this frustration when a tiny task becomes a one-week project. I hope you've encountered the opposite, too: a codebase where a task takes as long as you think it should.
LLMs can tolerate some aspects of code that we struggle with: variations in syntax and visual presentation, and keeping track of long execution flows in methods, functions, and classes. But structural quality remains important.
1. SRP: the single responsibility principle
I'm not going to explain what this principle is, only why it still matters.
The problem:
LLMs can generate code faster than humans can understand it. Without discipline, your codebase becomes a maze of moving parts, even though each piece is reasonable.
The solution:
SRP isn't optional anymore. It's the gating mechanism that prevents uncontrolled growth. Every generated class must have one clear responsibility. If the LLM generates five classes that are tangled together, reject the result and ask for refactoring.
What does this mean in practice? If a feature needs to touch five different services, maybe you're building it incorrectly. This is not only about complexity but also about LLM costs: the model needs to touch more files, and the context window might fill up quickly.
Simple steps to get started
1. Review code for architecture, not just correctness
❌ Old: "Does it work?"
✓ New: "Does it work? AND does it respect SRP?"
Questions to ask about every LLM-generated PR:
- “How many reasons could this class have to change?”
- “What does this depend on?”
- “Could I explain this to a new person in two minutes?”
- “Does this have side effects I can't see?”
2. Establish a “no monolithic services” rule
# This PR gets rejected, even if it works
class DataProcessor < ApplicationService
def initialize(data)
@data = data
end
def call
fetch_data
validate_data
transform_data
score_data
export_data
update_cache
send_webhooks
end
end
# Each of these should be its own service
3. Require explicit dependencies
# ❌ Hidden dependencies — rejected
class SurveyScorer
def call
complexity = SurveyComplexityScorer.call(survey_uuid)
# Where does SurveyComplexityScorer come from? Unknown.
end
end
# ✓ Explicit dependencies — accepted
class SurveyScorer
def initialize(complexity_scorer:)
@complexity_scorer = complexity_scorer
end
def call
complexity = @complexity_scorer.call(survey_uuid)
# Clear: I depend on this injected service
end
end
Level of abstraction
What “level of abstraction” means
- Code operates at one conceptual “height”: high-level logic, mid-level operations, or low-level details, without mixing them all together.
- High-level code reads like a specification: “Find brands available in waves X and countries Y,” rather than “Iterate over arrays and compute intersections.”
- Each method has one abstraction level: it does not decide a strategy and perform array manipulation in the same method.
- Low-level operations are hidden behind clear names:
compute_brand_intersection()instead of exposingarray1 & array2. - The logic is easy to follow: the reader doesn't need to switch between “What are we trying to do?” and “How does this loop work?”
- Each layer has clear vocabulary: domain terms such as waves, countries, and brands at the top; operations such as
map,select, andinjectat the bottom, without mixing them. - Intent is obvious: the code says why you're doing something, rather than burying it in how you're doing it.
- Bugs are easier to spot: “That calculation is wrong,” rather than “Wait, what is this loop trying to do?”
What “level of abstraction” does not mean
- Not “one level per file”: a file can contain different levels, as long as they are clearly separated.
- Not “never use low-level operations”: arrays, loops, and string manipulation are fine; they just shouldn't be mixed with policy decisions.
- Not “always extract every loop into a helper”: sometimes inline code is clearer. It's about consistency, not mechanical extraction.
- Not “avoid implementation details”: you need implementation details; just keep them out of high-level logic.
- Not “forbid technical terms”: use technical terms at the technical layer and domain terms at the domain layer.
- Not “every variable must have a description”: if a method stays at one abstraction level, the code is self-documenting.
- Not “a rigid hierarchy”: abstraction layers communicate and depend on each other. It's not a pyramid; it's a conversation.
- Not “separate from SRP”: they are different—one concerns reasons to change, while the other concerns conceptual clarity—but they work together.
How to achieve it
1. Read the code from the top down
Ask: if someone reads this function from top to bottom, can they understand the intent without reading the implementation details?
# ❌ Bad: High-level intent is buried in low-level details
def process_survey
survey = fetch_survey_from_api
parsed = JSON.parse(survey)
groups = parsed['groups'].map { |g| g.with_indifferent_access }
# ... 20 more lines of array operations
end
# ✓ Good: High-level intent is clear; details are hidden
def process_survey
survey = fetch_survey
groups = extract_groups_from(survey)
score_groups(groups)
end
private
def extract_groups_from(survey)
parsed = JSON.parse(survey)
parsed['groups'].map { |g| g.with_indifferent_access }
end
2. Name things at their abstraction level
# ❌ Bad: A domain term mixed with implementation details
def find_common_wave_countries
c_ids.flat_map { |c| hash[c] }.uniq & other_hash.keys
end
# ✓ Good: Domain terms at the high level; operations at the low level
def find_common_wave_countries
our_countries & available_countries
end
private
def our_countries
c_ids.flat_map { |c| hash[c] }.uniq
end
def available_countries
other_hash.keys
end
3. Use helper methods to hide complexity
# ❌ Bad: Business logic is mixed with loop details
def eligible_waves
all_waves.select do |wave|
next unless @wave_country_brands[wave].present?
@wave_country_brands[wave].values_at(*country_ids).all?
end
end
# ✓ Good: Business logic is clear; details are in a helper
def eligible_waves
all_waves.select { |wave| has_all_countries?(wave) }
end
private
def has_all_countries?(wave)
return false unless @wave_country_brands[wave].present?
@wave_country_brands[wave].values_at(*country_ids).all?
end
4. Keep one abstraction level per method
# ❌ Bad: Mixing levels
def calculate_score
# High-level: Decide what to score
components = [:burden, :options, :branching]
# Low-level: Extract from a JSON structure
burden = body.dig(:metrics, :burden).to_f
options = body.dig(:metrics, :options).to_f
branching = body.dig(:metrics, :branching).to_f
# High-level: Compute a weighted score
(burden * 0.35) + (options * 0.30) + (branching * 0.35)
end
# ✓ Good: Separate levels
def calculate_score
burden = extract_burden_metric
options = extract_options_metric
branching = extract_branching_metric
weighted_score([burden, options, branching])
end
private
def extract_burden_metric
body.dig(:metrics, :burden).to_f
end
def weighted_score(components)
components[0] * 0.35 + components[1] * 0.30 + components[2] * 0.35
end
5. Use the “Could I explain this to a non-programmer?” test
# ❌ Bad: A non-programmer can't follow this
def filtered_restrictions
filtered_wave_ids.select { |wid| wave_country_brands[wid]&.values_at(*country_ids)&.all? }.compact
end
# ✓ Good: The intent is clear even to a non-programmer
def filtered_restrictions
waves_that_have_all_requested_countries
end
How do you keep complexity under control?
Top comments (0)