Case Study 2: Architecting a Growing Simulation
"A module boundary drawn well is invisible for years; drawn badly it is a tax you pay on every commit."
Executive Summary
Where the first case study read a failure, this one asks you to design so the failure never arises. You
are the architect of a heat-simulation code that everyone agrees will keep growing — more physics, more
I/O formats, timers, tests, eventually parallel back-ends — and the decision in front of you is where to
draw the module boundaries. Draw them well and the code compiles fast, tests in pieces, and absorbs new
features without disturbing old ones. Draw them badly and you get recompilation cascades, a circular
dependency that will not build, and a heat_solver that has quietly grown tentacles into every other file.
We will inventory the responsibilities, lay them into layers, catch and kill a circular dependency before
it is ever compiled, apply a submodule exactly where it earns its keep, and finish with a compilable
skeleton and the compile order that falls out of the design.
Skills applied: one-responsibility module design and layered dependencies (§8.6); resolving circular dependencies (§8.5); interface/implementation separation with submodules for build speed (§8.3); private-state-behind-an-interface (§8.2); deriving compile order from a dependency graph (§8.5). The layout here is the one you will recognize in real codes in Chapter 36.
Background
The heat solver from this chapter's Project Checkpoint works, but it is about to grow. The near-term
roadmap adds: a namelist configuration reader (Chapter 7),
a field_t derived type (Chapter 9), timers around the update
(Chapter 28), VTK output
(Chapter 26), and
eventually OpenMP/MPI variants of the update (Part VIII).
Your job now is not to write all of that — it is to choose a module structure that will hold all of it
without being rebuilt from scratch each time. The right time to design the hierarchy is now, while it is
small enough to change cheaply.
Phase 1 — Inventory the Responsibilities
Design begins with a list of jobs, each nameable in a short phrase. If you cannot name a module's job crisply, it is doing too much. Here is the inventory:
| Responsibility | Proposed module | Why it is one job |
|---|---|---|
| The precision kind and physical constants | kinds |
one authoritative dp, used everywhere |
Run parameters (grid size, steps, dt) |
sim_config |
the "what to run" knobs, in one place |
| The numerical update | heat_solver |
the physics, and nothing else |
| Reading and writing data | heat_io |
all persistence, isolated from physics |
| Timing the run | timers |
measurement, orthogonal to everything |
| Orchestration (setup, time loop, output cadence) | program heat |
the conductor, owns no algorithm |
Six responsibilities, six units. Note what is not combined: the physics (heat_solver) is kept separate
from the I/O (heat_io) even though both touch the field, because "how you compute the next state" and
"how you write it to disk" change for completely different reasons and at completely different times. That
separation is the single most important boundary in the whole design, and Phase 3 will show why.
Phase 2 — Lay the Modules into Layers
Now assign each module a layer, with the rule from §8.6: depend downward only. Foundations at the bottom, orchestration at the top, every arrow pointing down.
Layer 3 (top) program heat ← owns the time loop; no algorithm
/ | | \
Layer 2 heat_solver heat_io timers ← the "what": physics, I/O, timing
\ | | /
Layer 1 sim_config | / ← the run's parameters
\ | /
Layer 0 (bottom) kinds ← dp: the shared foundation
Read every arrow as "uses." kinds uses nothing. sim_config uses kinds. The three Layer-2 modules use
kinds (and heat_solver/heat_io use sim_config for the dimensions). The driver uses everything
below it. The graph is acyclic and shallow — the two properties that make a hierarchy buildable and
comprehensible. If any arrow pointed sideways (peer to peer) or upward, that would be the alarm bell we
investigate next.
Phase 3 — Catch and Kill a Circular Dependency
Here is the design mistake that real teams make, and it is worth watching happen. A developer wants
mid-step debug output, so they make heat_solver use heat_io to call a dump routine inside step.
Later, another developer wants heat_io to re-normalize a field before writing it, so they make heat_io
use heat_solver. Now:
heat_solver ──uses──▶ heat_io
▲ │
└────────uses───────┘ ← a cycle: neither can compile first
This will not build. heat_solver needs heat_io.mod to compile, heat_io needs heat_solver.mod to
compile, and neither .mod can be created first (§8.5). The compiler rejects it — which, as §8.5 argued,
is a feature: the tool caught a design smell before it could rot.
The fix is not a mechanical trick; it is to notice that the cycle means a responsibility is misplaced. Two clean resolutions, in order of preference:
- Move the shared need down a layer. Both routines really want the same low-level operation
(a field statistic, a normalization). Put that operation in a lower module — say,
field_opsin Layer 1 — that bothheat_solverandheat_iouse. The cycle vanishes because both now depend downward on a common foundation instead of sideways on each other. This is almost always the right answer, and it usually reveals a module you should have had anyway. - Push the I/O out of the algorithm entirely. Better still, question whether
stepshould do I/O at all. It should not: writing to disk is the driver's job, done between steps, not the solver's job done inside one. Keepheat_solverfree of any I/O, let the driver callreportafter each step, and the coupling that created the cycle never exists. Astepthat touches no files is also the one you can later hand to OpenMP or MPI unchanged.
We take resolution (2): the solver computes, the driver orchestrates, and heat_solver never uses
heat_io. (Had the coupling been genuinely irreducible, §8.3's submodule is the escape hatch — a
submodule (heat_io) may use heat_solver without forming a cycle in the module graph. Reach for it
only when a downward refactor is truly impossible.)
Phase 4 — Apply a Submodule Where It Pays
heat_solver is the module that will change most often — every numerical improvement, every parallel
back-end, touches its body. But its interface, step(field, alpha, dt), is frozen for the rest of the
book. That is the exact situation §8.3 was built for: put the stable interface in the module and the
volatile body in a submodule, so that recompiling the physics does not recompile the driver, the I/O, or
anything else that merely calls step.
! heat_solver.f90 — the stable interface (rarely changes)
module heat_solver
use kinds, only: dp
implicit none
private
public :: step
interface
module subroutine step(field, alpha, dt)
real(dp), intent(inout) :: field(:,:) ! dp by host association
real(dp), intent(in) :: alpha, dt
end subroutine step
end interface
end module heat_solver
! heat_solver_impl.f90 — the volatile body (changes every optimization)
submodule (heat_solver) heat_solver_impl
implicit none
contains
module subroutine step(field, alpha, dt)
real(dp), intent(inout) :: field(:,:)
real(dp), intent(in) :: alpha, dt
real(dp), allocatable :: old(:,:)
real(dp) :: lap
integer :: i, j, nx, ny
nx = size(field,1); ny = size(field,2)
allocate(old(nx,ny)); old = field
do j = 2, ny-1
do i = 2, nx-1
lap = old(i-1,j)+old(i+1,j)+old(i,j-1)+old(i,j+1) - 4.0_dp*old(i,j)
field(i,j) = old(i,j) + alpha*dt*lap
end do
end do
end subroutine step
end submodule heat_solver_impl
With the rest of the skeleton (kinds, a minimal sim_config, heat_io, and the driver):
module sim_config
implicit none
private
integer :: nx = 0, ny = 0, n_steps = 0
public :: configure, get_dims
contains
subroutine configure(nx_in, ny_in, ns_in)
integer, intent(in) :: nx_in, ny_in, ns_in
nx = nx_in; ny = ny_in; n_steps = ns_in
end subroutine configure
subroutine get_dims(nx_out, ny_out)
integer, intent(out) :: nx_out, ny_out
nx_out = nx; ny_out = ny
end subroutine get_dims
end module sim_config
module heat_io
use kinds, only: dp
implicit none
private
public :: report
contains
subroutine report(step_no, field)
integer, intent(in) :: step_no
real(dp), intent(in) :: field(:,:)
print '(a, i0, a, f8.2)', 'step ', step_no, ': max = ', maxval(field)
end subroutine report
end module heat_io
module kinds
implicit none
private
public :: dp
integer, parameter :: dp = selected_real_kind(15, 307)
end module kinds
program heat
use kinds, only: dp
use sim_config, only: configure, get_dims
use heat_solver, only: step
use heat_io, only: report
implicit none
real(dp), allocatable :: field(:,:)
integer :: nx, ny
call configure(4, 4, 1)
call get_dims(nx, ny)
allocate(field(nx, ny)); field = 0.0_dp; field(1,:) = 100.0_dp
call step(field, 0.1_dp, 1.0_dp) ! solver computes ...
call report(1, field) ! ... driver orchestrates the I/O
end program heat
The driver is the only unit that touches both the solver and the I/O, and it touches each through a small, checked interface. Compile with the modules in dependency order (the submodule right after its parent):
$ gfortran -std=f2018 -Wall kinds.f90 sim_config.f90 heat_solver.f90 \
heat_solver_impl.f90 heat_io.f90 heat.f90 -o heat && ./heat
step 1: max = 100.00
Verify by hand: the $4\times4$ field starts with a $100$-degree top edge and zeros elsewhere; one step
raises the two interior cells beside the hot edge to $10.00$ and leaves the edge at $100.00$, so
maxval(field) = 100.00. The number is trivial on purpose — the case study is about the structure that
produced it, and the structure is now one a growing code can live in.
Phase 5 — The Compile Order, and Why the Design Earns It
The compile order is not something you invent; it falls out of the layer diagram, read bottom to top:
| Order | Unit | Depends on |
|---|---|---|
| 1 | kinds |
nothing |
| 2 | sim_config |
(nothing here; kinds if it used dp) |
| 3 | heat_solver (interface) |
kinds |
| 4 | heat_solver_impl (submodule) |
heat_solver (its .smod) |
| 5 | heat_io |
kinds |
| 6 | program heat |
all of the above |
Because the graph is acyclic, such an order always exists (it is a topological sort); because it is shallow, the order is obvious and the build is fast. And the submodule split means that tomorrow, when you replace the placeholder update with Chapter 24's real five-point stencil, only unit 4 recompiles — the interface (unit 3), the I/O, the config, and the driver are untouched, and on a large code that is the difference between a fast edit-build-test loop and a slow one. The design paid for itself the first time the physics changed, which in a research code is roughly every day.
Discussion Questions
- Phase 3 offered two fixes for the circular dependency. Give a concrete example of a coupling where fix (1) — a shared lower module — is clearly better than fix (2), and one where the reverse is true.
sim_configstores its parameters in private module variables reached throughconfigure/get_dims. Contrast this with apublicdata module (like thegridmodule in Case Study 1). When is each the right call?- The driver is the only unit that knows about both
heat_solverandheat_io. Why is concentrating that knowledge in the top layer a good thing, and what would you lose ifheat_solvercalledreportitself?
Your Turn: Extensions
- Option A. Add the
timersmodule to the skeleton: give ittic()/toc()wrappingsystem_clock, place it in the correct layer, and have the driver time thestepcall. Which existing files must change, and which must not? State the new compile order. (This previews Chapter 28.) - Option B. Suppose
heat_iomust grow both a text writer and a VTK writer that share a filename helper. Design the sub-structure: one module with a private helper, or a small module plus a submodule? Justify your choice in terms of build cost and clarity. - Option C. Draw the dependency graph your design would have if you (wrongly) merged
heat_solverandheat_iointo onephysics_iomodule. List three concrete problems that merge would cause as the code grows, tying each to a principle from §8.6.
Key Takeaways
- Design the hierarchy from a list of one-phrase responsibilities; when you cannot name a module's job, it is doing too much. Keep the physics and the I/O in separate modules even though both touch the field — they change for different reasons.
- Depend downward only. An acyclic, shallow graph is buildable and comprehensible; a sideways or upward arrow is a design smell, and a circular dependency is one the compiler will (usefully) refuse.
- Kill a cycle by moving the shared need down to a common module, or by pushing a misplaced
responsibility (like I/O inside
step) out to the layer that owns it. Submodules are the escape hatch only when a refactor genuinely cannot break the cycle. - Put a stable interface in the module and a volatile body in a submodule, so the code that changes most costs the least to rebuild. The compile order then falls out of the layer diagram for free.