Week 6 [Mon, Sep 14th] - Topics

Detailed Table of Contents



Guidance for the item(s) below:

The next UML model we'll be learning is sequence diagrams. As before, focus on learning how to interpret these diagrams as you'll need to interpret some sequence diagrams in tP documentation pretty soon. Drawing sequence diagrams will be covered in a future week.

[W6.1] Modeling: Sequence Diagrams

W6.1a

Design → Modelling → Modelling Behaviors → Sequence diagrams - basic

Video Q+

Sequence diagrams model the interactions between various entities in a system, in a specific scenario. Modeling such scenarios is useful, for example, to verify the design of the internal interactions is able to provide the expected outcomes.

Example Modeling how components of a system interact with each other to respond to a user action.
Example Modeling how objects inside a component interact with each other to respond to a method call it received from another component.

UML Sequence Diagrams → Introduction

Loading...

UML Sequence Diagrams → Basic Notation

Loading...

UML Sequence Diagrams → Loops

Loading...

UML Sequence Diagrams → Object Creation

Loading...

UML Sequence Diagrams → Minimal Notation

Loading...


Exercises:

Explain Sequence Diagram about Machine


Draw a Sequence Diagram for the code (PersonList, Person, Tag)


Find notation errors in Sequence Diagram



W6.1b

Design → Modelling → Modelling Behaviors → Sequence diagrams - intermediate

Video Q+

UML Sequence Diagrams → Object Deletion

Loading...

UML Sequence Diagrams → Self-Invocation

Loading...

UML Sequence Diagrams → Alternative Paths

Loading...

UML Sequence Diagrams → Optional Paths

Loading...

UML Sequence Diagrams → Calls to Static Methods

Loading...


Exercises:

What’s going on here?


Explain Sequence Diagram (ParserFactory)


Draw Sequence Diagram for printing a quote



W6.1c

Design → Modelling → Modelling Behaviors → Sequence diagrams - advanced

UML: Sequence Diagrams: Parallel Paths

Loading...

UML: Sequence Diagrams: Reference Frames

Loading...



Guidance for the item(s) below:

The tP developer guide also has something called an architecture diagram. Let's learn how to interpret them too (drawing them will be covered in a future week).

[W6.2] Architecture Diagrams

Video

W6.2a

Design → Architecture → Introduction → What

When you write a small program by yourself, you can hold its structure in your head. You know where each piece of functionality lives, and you can usually predict what a change will affect.

That becomes much harder as the software and its team grow. Several people add code to the same program at once, and none of them has read all of it. Questions that once seemed trivial start to matter:

  • Where does this new code belong?
  • Where does each part run?
  • How do the parts communicate?
  • Which qualities matter most — ease of change, speed, reliability, security?

Software architecture is the set of significant decisions about a system's overall organization. It identifies the major parts, their responsibilities, how they interact, and how they are deployed.

Example Consider a desktop invoice-management application in which users create invoices, set due dates, and mark invoices as paid. A possible architecture has four parts:

Each arrow in the diagram is a dependency: the part at the tail needs the part at the head.

  • Ui: displays information and receives user input
  • Logic: interprets user actions and carries out operations
  • Model: represents invoices and other data while the program runs
  • Storage: reads data from and writes it to persistent storage

An architecture description deliberately leaves out most of what is inside each part. It does not say how many classes are in Logic, which one validates a user command, or which collection holds the invoices. Those are detailed design decisions.

Architecture and detailed design differ in how far a decision reaches, not merely in how big it is. An architectural decision constrains what the other parts can do, shapes the qualities the system can achieve, and is expensive to reverse once the rest of the system is built on it.

Example Deciding that persistent data belongs in a separate Storage component is architecture. Deciding that Storage uses one class per file format is detailed design.

The boundary is not absolute. An internal choice becomes architecturally significant when it strongly affects the rest of the system.

Example A change to the data representation to make the application able to handle a million invoices can be architecturally significant, affecting more than the internals of the Storage component.

Every software system has an architecture, even when nobody designed one deliberately. An unplanned program still has parts and dependencies; they are simply accidental, undocumented, and usually tangled. The value of deciding an architecture on purpose is that the team shares one, understands it, and can reason about it.

An architecture is a shared and evolving technical understanding. A team sets an initial architecture early — the decisions are expensive to reverse later — then tests it against real requirements and revises it deliberately when those requirements change. Larger organizations may give one person an architect role to hold the technical vision, but the architecture still has to be understood by everyone building the system.

An architecture is also more than a diagram. Although an architecture is often represented by a diagram (like the one in the example above), a diagram shows one view. The architecture also includes the constraints everyone must work within and the reasons behind the choices.

Example The fact that the application stores data locally because it must work without a network connection is also part of the architecture, although it may not be captured by the architecture diagram.

The software architecture of a program or computing system is the structure or structures of the system, which comprise software elements, the externally visible properties of those elements, and the relationships among them. Architecture is concerned with the public side of interfaces; private details of elements—details having to do solely with internal implementation—are not architectural.

-- Software Architecture in Practice (2nd edition), Bass, Clements, and Kazman

The definition above is widely quoted in the literature. "Externally visible properties" means what promises to do, not how it does it.


Exercises:

Statements about architecture



W6.2b

Design → Architecture → Introduction → Why

A useful architecture limits how far many changes spread through the system.

Example Consider the following changes to an invoice manager application:

  • Store invoices in a database instead of a file: The main change should stay inside Storage, as long as its existing interface can still express what is needed.
  • Add a command-line interface alongside the graphical one: Most new code belongs in Ui; both interfaces can drive the same Logic.
  • Test invoice operations without launching a window: Because Logic does not depend on Ui, tests can call it directly.
  • Let several developers work at once: Clear component boundaries reduce how often two people must edit the same file.

Architecture does not restrain the spread of every change. That is not necessarily an architectural failure: some changes genuinely cross several responsibilities.

Example Adding a new property to every invoice can touch the interface, the rules, the model, and the storage format.

Architecture also shapes the system's quality attributes — the properties that decide whether the software is good enough for its purpose. Common quality attributes include:

  • Modifiability — how easily it can be changed
  • Performance — how quickly it responds
  • Reliability — how well it keeps working when something fails
  • Security — how well it protects data and resists misuse
  • Deployability — how easily a new version reaches users

Quality attributes often conflict, and that is the central difficulty of architecture. Splitting a system across machines can allow independent scaling while making it slower and adding network failures. Adding component boundaries can limit how far a change spreads while adding interfaces that developers must learn and maintain.

There is no architecture that is best on every attribute — only one that fits the qualities and likely changes that matter for the system being built.


W6.2c

Design → Architecture → Introduction → Components, interfaces, and dependencies

Three concepts appear frequently in architecture descriptions: components, interfaces, dependencies.

A component is a major part of the system with one coherent responsibility.

Example Storage's work concerns persistent data. A single Invoice class is too small to be an architectural component.

An interface is the agreement stating how the rest of the system may use a component. It is more than a list of operations. A complete interface also covers:

  • the data each operation accepts and returns;
  • what happens when an operation succeeds or fails; and
  • for a component reached over a network, the format and protocol of the messages.

Example Storage might offer saveInvoices(invoices) and readInvoices(), and specify that a corrupt file causes a particular error rather than a crash.

A dependency exists when one component relies on another to do its job. Dependencies have a direction.

Example Logic depends on Storage, because it cannot save without it. Storage does not depend on Logic: it can be compiled, tested, and understood without knowing that commands exist.

A component is not a special programming construct. There is no component keyword; a component is whatever unit of code the team agrees to treat as one part with one responsibility. It may be a separate library, a language-level module, or an entirely separate program reached over a network.

Example In a Java project a component is often a package (or group of packages) plus a type declaring what it offers:

src/main/java/invoicemanager/ ├── ui/ ├── logic/ ├── model/ └── storage/ ├── Storage.java ← declares what Storage offers ├── JsonStorage.java ← one implementation └── ... ← other classes, private to this component

A dependency arrow describes reliance, not necessarily a method call. Whether that reliance is a method call, a message, or a network request is usually indicated in the .


Exercises:

Which component changes?



W6.2d

Design → Architecture → Architecture Diagrams → Different diagrams of one system

Architecture diagrams can differ in what they show and how much detail they show. First consider two common views of a system:

  1. A logical view shows the major parts and which parts depend on which. It says nothing about where any of it runs.
  2. A deployment view shows where the parts actually run. For the desktop invoice manager the deployment view is almost boring: all four parts run inside one program, on the user's computer.

Example The same invoice manager, but two views of the architecture:

Each arrow in the logical view is a dependency. The deployment view has no arrows because all four parts run inside one program on one computer.

In this example, all the interesting structure is in the logical view. If a server is added to let several users share invoices, both views change. The deployment view changes dramatically; the logical view changes in a smaller but real way.

Keeping the views separate prevents a common confusion.

Example "This system has four components" and "this system runs on four computers" are entirely different statements, and one diagram that blurs them will mislead everyone who reads it.

The 4+1 view model extends the idea above by describing a system using five standard views. Four cover the system's functionality, runtime processes, code organization, and deployment. The "+1" uses scenarios to show how the system works in specific situations.

Switching to the aspect of how much details a diagram show, the design of a small system can be shown at a single level of detail. One diagram can cover the entire system.

Example This class diagram of se-edu/addressbook-level2 depicts the design of the entire software.

The design of a bigger system needs to be created and shown at multiple levels: higher-level diagram shows the major parts, and lower-level diagrams zoom into individual parts to show their internal design.

Example This architecture diagram of se-edu/addressbook-level3 depicts the high-level design of the software.


dashed arrows: dependencies
solid arrows: associations

Lower-level designs of some components of the same software:




The C4 model is a specific form of multi-level design views. It organizes diagrams of a system's static structure into four core levels, which zoom in from the system's surroundings to its code:

  1. A system context diagram shows the system, its users, and the other systems it interacts with.
  2. A container diagram zooms in to show the system's applications and data stores. C4 calls each of these a container, which is not the same as a Docker container.
  3. A component diagram zooms into one container to show its main components and their relationships.
  4. A code diagram zooms into one component to show how it is implemented. A UML class diagram can be used at this optional level and is often generated by an IDE.

Example The addressbook-level3 diagrams above roughly match the two lowest C4 levels: the architecture diagram is a component diagram, and the class diagrams are code diagrams.

Neither 4+1 nor C4 is a new diagram notation. 4+1 organizes different kinds of architectural information, while C4 provides zoom levels for static structure. Their diagrams can use an established notation such as UML, or an ad hoc notation made up of boxes, arrows, and text labels.

Both models are meant for large systems, larger than our invoice manager example. They add value when a system has many parts running in several places, and its architecture must be explained to readers who each need a different slice of it.


Resources:
  • C4 model -- the official reference, with definitions and examples for each diagram type
  • 4+1 view model -- an overview of the five standard views

W6.2e

Design → Architecture → Architecture Diagrams → Reading

Architecture diagrams have no universally adopted notation. Unlike UML class diagrams, there is no rulebook: different teams use different shapes, colors, and arrows. A reader must first work out what the notation means.

Read an architecture diagram by asking four questions in order:

  1. What view is this? Code organization, deployment, communication, or something else — each shows different information.
  2. What are the major parts? Usually the boxes. Expect a handful, not thirty.
  3. What is each part responsible for? Names, labels, and nearby text should make this clear.
  4. What do the relationships mean? Follow each arrow's direction, and check the legend for what an arrow means.

Example Here are two real architecture diagrams, from actual projects, drawn by different teams in different notations.



dashed arrows: dependencies
solid arrows: associations




Follow up notes for the item(s) above:

AI's impact on » Architecture Diagrams

AI makes architecture diagrams easier to produce, but more important to check. An agent can inspect code and quickly draft or update diagrams, so drawing every box and arrow by hand matters less. However, it may choose the wrong view, include too much detail, or mistake the current code structure for the intended architecture.

As agents make larger changes across unfamiliar code, developers still need to select the right view and level of detail, explain the notation, and verify that each diagram accurately communicates the system’s architecture.

Guidance for the item(s) below:

The tP uses logging as one of its error handing strategies. As you'll be reading tP code soon, let's make sure you can recognize logging code when you see them.

[W6.3] Logging

Video

W6.3a

Implementation → Error Handling → Logging → What

Logging is the deliberate recording of certain information during a program execution for future reference. Logs are typically written to a log file, but it is also possible to log information in other ways e.g., into a database or a remote server.

Logging can be useful for troubleshooting problems. A good logging system records some system information regularly. When problems occur in a system e.g., an unanticipated failure, the associated log files may indicate what went wrong, and actions can then be taken to prevent it from happening again.

A log file is like the of an airplane; it does not prevent problems, but it can be helpful in understanding what went wrong after the fact.


Exercises:

Logging vs blackbox



W6.3b

Implementation → Error Handling → Logging → How

Most programming environments come with logging systems that allow sophisticated forms of logging. They have features such as the ability to enable and disable logging easily or to change the logging .

Example This sample Java code uses Java’s default logging mechanism.

First, import the relevant Java package:

import java.util.logging.Level;
import java.util.logging.Logger;

Next, create a Logger:

private static Logger logger = Logger.getLogger("Foo");

Now, you can use the Logger object to log information. Note the use of a for each message. When running the code, the logging level can be set to WARNING so that log messages specified as having INFO level (which is a lower level than WARNING) will not be written to the log file at all.

// log a message at INFO level
logger.log(Level.INFO, "going to start processing");
// ...
processInput();
if (error) {
    // log a message at WARNING level
    logger.log(Level.WARNING, "processing error", ex);
}
// ...
logger.log(Level.INFO, "end of processing");

Resources:

Tutorials:

Best Practices:



[W6.4] Debugging

W6.4a

Implementation → Debugging → What

The debugging topics of this textbook draw substantially on The Debugging Book by Andreas Zeller et al., in particular its terminology of defects, infections, and failures, and its treatment of debugging as an application of the scientific method. Further material was adapted from the debugging readings of MIT 6.102, UC Berkeley CS61B, Stanford CS107, UW CSE 332, and CMU 15-213.

Debugging is the process of finding the cause of a known problem in a program, and fixing it. The hard part is usually the diagnosis rather than the correction.

To debug well, we distinguish four things that we loosely refer to as 'the bug':

  • A mistake is the human (or AI) act that started it all.
    Example You misremembered that list indices start at 1.
  • A defect is the resulting error in the software. This is what most people mean by 'a bug'. It is usually in the code, but it can also be in configuration, in data left by an earlier version, in a dependency, in the deployment, or in a requirement that was wrong to begin with.
    Example A loop that starts counting from the wrong index.
  • An infection is the resulting error in the program state at run time. When the defective line executes, some variable now holds a wrong value. Infections can spread; for example, an 'infected' variable holding a wrong value can cause another variable to hold a wrong value in turn.
  • A failure is the externally visible wrong behavior.
    Example A total shown to the user that is too small, or a crash.

These four form a chain, and each link can be far from the next:

A big part of debugging is the backward search for the defect by starting from the observed failure.

This chain also explains why bugs hide: a defect infects the state only when that line executes, and an infection becomes a failure only if it propagates out to something observable. So, a defect can sit in daily-executed code for months unnoticed.

Example The shopping cart code given below prints the correct total, but then the cart appears empty.

class Cart {
    private final List<Item> items = new ArrayList<>();

    void add(Item item) {
        items.add(item);
    }

    List<Item> getItems() {
        return items;
    }

    int computeTotal() {
        int total = 0;
        List<Item> pending = getItems();
        while (!pending.isEmpty()) {
            total += pending.remove(0).price();
        }
        return total;
    }
}

Cart's intended contracts: computing a total must not change the cart, and getItems() lets callers read the items without owning the list.

  • Mistake: the programmer assumed getItems() hands back a copy.
  • Defect: getItems() hands out the live internal list, which computeTotal() then empties — breaking both contracts at once.
  • Infection: after computeTotal() returns, the cart's own items list is empty.
  • Failure: the next attempt to display the cart shows nothing.
Why debugging is hard

Some factors that make debugging hard are:

  • The distance between defect and failure: the crash site is not always the crime scene; so focusing on the code around the error message might not always lead you to the cause.
  • You cannot inspect everything — a running program holds an enormous amount of state, changing at every step, and choosing which small part to look at is hard.
  • Wrong assumptions that caused the bug can hamper debugging as well: if a wrong assumption caused you to create the defect, debugging while holding the same wrong assumption can reproduce the same blind spot. This is why debugging must be driven by evidence from the running program, not by reasoning alone.
How not to debug
  • Bad Stare and hope — reading the code and waiting for the bug to reveal itself. This inspects the code but not the state. It is fine as a 30-second first try but a poor use of time for an extended attempt.
  • Bad Shotgun debugging — changing whatever looks suspicious and re-running to see whether it helped. Most such changes neither confirm nor eliminate any explanation, and unrelated edits accumulate.
  • Bad Fixing the symptom instead of the cause. The failure goes away and the defect stays.
    Example Special-casing the input that fails, or wrapping the crash in an empty catch block.
  • Bad Keeping no record of what you tried. Without notes you will re-test explanations you already eliminated, lose your place when interrupted, and be unable to hand the problem over.

What these have in common is that they produce activity without producing useful information. A productive debugging step is one that rules something out.

Adding temporary print statements is not necessarily bad. A few prints chosen to answer a specific question are legitimate, and in production, embedded, or concurrent settings they are sometimes the only tool available. However, this technique alone is not enough to tackle most non-trivial bugs.


W6.4b

Implementation → Debugging → How

Systematic debugging involves following tasks, whose first letters make up the mnemonic TRAFFIC (credit: Andreas Zeller).

  • rack — state what the correct behavior is, and record the problem somewhere durable.
  • eproduce — make the failure happen on demand.
  • utomate and simplify — turn the reproduction into a one-step test, and reduce it to the smallest case that still fails.
  • ind origins — list the places where the state could first have gone wrong.
  • ocus — pick the most likely candidate origin, and state what it predicts.
  • solate — run the check that decides if the candidate origin is the actual origin, and conclude.
  • orrect — fix the cause, confirm it, and guard against recurrence.

TRAFFIC is a map rather than a mandatory sequence. Some tasks can be done in a loop before it lands on the cause, and you can reorder the other tasks as you see fit.

The most common mistake is jumping straight to C. Starting at 'correct' and working backwards is how shotgun debugging happens. This text below covers the six tasks TRAFFIC except C, which is covered separately.

Know when to stop for the day: Debugging is unusually sensitive to fatigue, because the whole activity consists of holding a model of the program in your head.

Track

The first step of debugging should be stating what the correct behavior is, and why. Without that you have nothing to compare the program against, and you risk searching code that was right all along. State the expectation in a testable form: for this input, that exact result.

Sometimes the fault is in the test, not in the code under investigation. It is worth considering early, because it is easy to lose hours to a test that was wrong all along.

Reproduce

A reliable reproduction makes every experiment cheap and is a way to confirm afterwards that the fix worked. Reproducing means recreating everything the failure depends on, e.g., the input data, the program version, the environment and , the sequence of actions, and the starting state, such as leftovers from a previous run.

When you cannot reproduce a failure you can still investigate it. Instead of running experiments you mine the evidence left behind: stack traces, logs, , , and the differences between runs that failed and runs that did not. The immediate goal becomes making the failure more observable or more frequent. Moving a bug from 'once a week' to 'one run in five' is real progress.

Automate and simplify

Automate the reproduction as a test case as early as you can. Turning "launch the app and perform these six steps" into a one-second command is what makes the hypothesis loop cheap, and it can become the regression test once you have a fix.

Why simplify? The smaller the failing case, the smaller the search space -- every element you can remove while the failure persists eliminates a whole category of possible causes. Some directions you can try:

  • Shrink the input space: Cut the input space in half, test each half, keep whichever still fails, repeat. When it works it is very cheap, and needs no insight into the code. But halving does not always work: sometimes neither half fails, because the failure needs two elements the halving separated.
  • Simplify the code path: Strip away unrelated features, configuration, and calls until only the failing core remains. Now you have less code to search for the defect.

Example A 500-line configuration file makes the app crash at startup. Halving gets nowhere: neither half crashes, because the failure needs one setting from each. Removing settings one at a time from the full file isolates the pair — a theme entry and a locale entry, each harmless alone.

Find origins

An origin is a place where the state could first have gone wrong: before it the state is correct, after it the state is infected, and the cause sits at that boundary. This step often produces a list of candidate origins rather than a single answer. Some techniques you can follow:

  • Reason backwards from the wrong value. Ask which statements could have produced it, then which produced their inputs. Following data and control dependencies backwards is called backward slicing — it narrows the candidates rather than pinpointing them, since a slice reliably contains every statement that could be responsible, usually along with some that could not.
  • Explain the code to someone, line by line (aka rubber duck debugging): explaining the code to a patient friend, in a written explanation, to an AI, or even to an inanimate object like a rubber duck works for a real reason: articulating what each line does forces you to state assumptions you had taken for granted, and you often spot the wrong assumption mid-sentence ("...then, that object is passed to ... wait, that can't be right ... that object should not cross this boundary!").
  • Read the evidence you already have before generating candidates from the code alone. An exception message names the expression that failed, a stack trace names the calls that led there, and a diff names what changed recently.
Focus

Candidate origins are not equally likely, and the order you check them in decides how long the search takes.

  • Prefer recently changed code to long-stable code, your code to library code, and library code to the compiler or the operating system. This is a starting bias rather than a rule (i.e., it is possible for the bug to be in the library code or even the OS, although less likely).
  • Turn the chosen origin into a prediction before you check it. State what you would observe if it is guilty and what you would observe if it is innocent. This makes your debugging more systematic and helps you narrow down the candidates faster.
Iisolate

Isolating means running one check, discarding the part of the search space it rules out, and repeating until you find what needs to be fixed.

  • Binary search over the execution can be used to find when the state gets infected. Pick a point roughly halfway through the suspect region, pause, and ask one question: is the state already wrong? If yes, look earlier; if no, look later. Each check roughly halves the region still under suspicion.
  • Binary search over versions can be used to find when the code used to work. If it passed last week, the cause is in one of the commits since -- bisect the history rather than the code. git bisect automates this, and works best with small, self-contained commits. Martin Fowler calls this Diff Debugging.
  • Swap a suspect component for one you trust. If the failure survives the swap, that component is very likely not responsible.
  • Change one thing at a time, or the outcome will not tell you which change produced it.
  • Record the conclusion, then start the next turn of the loop from the narrowed region until you find what needs to be fixed.

Example Binary search along the execution, on a run too long to watch: a 10,000-row import produces the right running total at the start and the wrong one at the end, and nothing in between is visible. Pause at row 5,000 and ask one question — is the total already wrong? If it is, the cause lies in the first half, so pause next at row 2,500; if it is not, pause at row 7,500. Fourteen such checks reduce 10,000 rows to one, and none of them requires understanding the code — only the ability to say whether the state is already wrong.

The cheapest bug to debug is the one that announces itself, and most of what makes code debuggable is decided long before the bug exists.

  • Fail fast. Check preconditions and invariants on entry to a method, so an infection surfaces close to its origin instead of ten frames later. (related: defensive programming, assertions)
  • Keep scopes small. A variable visible across three lines has only three lines that could have changed it; a field visible across a class has the whole class.
  • Prefer immutability. A value that cannot change cannot be changed wrongly, which removes an entire category of "what modified this?" investigations — including the one in the cart example.
  • Develop incrementally, testing as you go. When only twenty lines are new, the defect is almost certainly in those twenty lines. This is a high-value habit often abandoned under time pressure.
  • Use the static checks you already have — compiler warnings, IDE inspections, linters, @Override, generics, final. A defect caught here costs no debugging at all.
  • Log at component boundaries, so that logs can help you narrow the search to specific components.

Each of these shortens the distance between defect and failure, which is the root of the difficulty.

Correct

Covered in a separate section, titled Fixing.


W6.4c

Implementation → Debugging → Tools

Every way of looking inside a running program is a probe — a means of answering one specific question about its state. Here are some widely used probes:

  • Print statements are the cheap to start with and expensive to iterate with. They are easy to insert, but each needs an edit-build-run cycle, each edit is a chance to introduce a fresh defect, and leftovers reach production if you forget them.
  • Logging is the disciplined, permanent form of printing. and , log statements can stay in the code — so they are still there when the failure happens on a user's machine at 3 a.m., where no debugger can reach.
  • Assertions are probes that check themselves. Rather than printing a value for you to examine, an assertion states what it should be and fails immediately when it is not, turning a silent infection into a loud, located failure. If you use Java's assert statement, enable assertions in your run configuration or it will do nothing; test-framework (e.g., JUnit) assertions are separate and always run during testing.
  • A debugger is a tool that can attach itself to an executing program and can ask questions interactively, without changing the code at all.

The useful question is never "print statements or debugger or ___?" but "what is the cheapest probe that answers this question?" Some probes come out once the bug is found (e.g., a temporary print statement); others are meant to stay (e.g., a permanent log statement).

As a rough guide:

  • reproducible and local → debugger
  • needs to survive into production → logging
  • want to catch the problem at its origin → assertions
Using a debugger

A debugger is a tool that lets you pause a running program, then inspect and control it from the inside, without modifying its code. You choose the lines where the program should stop. When execution reaches one of them, the program freezes mid-run and waits for you. You can then look at the current values of its variables, see the chain of method calls that led there, and let it continue one line at a time. Most IDEs, such as IntelliJ IDEA, come with a debugger built in. There are also standalone debuggers that you control by typing commands, such as GDB for C and C++, and jdb for Java.

If you have not used a debugger tool before, watching a 'debugger in action' video such as the one below is recommended before reading the rest of this topic.

Debugging in IntelliJ


A debugger works from outside your program, which is what makes it different in kind from printing. The debugger runs as a separate process. It either launches your program under its control or attaches to one that is already running, then asks the operating system, or the JVM for a Java program, to pause the program at the chosen lines and report its state back. Your code stays untouched throughout. With print statements, every new question about the program means adding another print, rebuilding, and re-running. With a debugger, the paused program keeps all its state, so asking one more question costs seconds rather than another edit-build-run cycle.

Breakpoints determine where the program pauses. A breakpoint is a marker that tells the debugger to stop the program at a particular spot. In an IDE, you usually set one by clicking in the margin next to a line number, which puts a red dot there. In GDB, you type a command such as break main.c:42 instead. Either way, the debugger keeps track of the breakpoint, so your source file does not change. Breakpoints take effect only when you start the program through the debugger, e.g., with IntelliJ's Debug button rather than Run. Started the normal way, the program runs straight past them. Breakpoints come in several kinds:

  • A line breakpoint pauses when execution reaches a given line.
  • A conditional breakpoint pauses only when a condition holds. This makes debugging the 4137th iteration of a loop feasible at all, and it is the feature beginners most often do not know exists.
    Example Pausing only when i == 4137.
  • An exception breakpoint pauses at the moment an exception is thrown, before the stack unwinds and discards the state you need.
  • A field watchpoint pauses when a field's value changes rather than at a location — the right tool for "what is setting this to null?"

Stepping commands determine how execution advances. Once you have examined the paused program, a stepping command lets it run a little further and then pauses it again, so you can see what changed. In an IDE, each command has a toolbar button and a keyboard shortcut. The common ones are:

  • Step over runs the current line as one step, including any methods it calls, and pauses at the next line.
  • Step into moves into the method called on the current line and pauses at the first line of that method.
    Example If the current line is total += item.price();, step over pauses at the line after it, while step into pauses inside price().
  • Step out runs the rest of the current method and pauses back in the method that called it.
  • Run to cursor keeps running until execution reaches the line your cursor is on, like a one-time breakpoint.
  • Resume keeps running until the program reaches the next breakpoint or ends.

Inspection views let you examine details of the state the program is currently paused at. Some common inspection views:

  • The call stack shows how execution reached this point, and selecting any frame reveals that method's variables. The cause is often several frames above where the program stopped.
  • The variables view shows the values currently in scope, and watches track a chosen expression as you step.
  • evaluate expression runs arbitrary code at the paused point, turning passive inspection into a live experiment: you can test a hypothesis without editing or restarting. One caution — evaluating really does run the code, so calling a method that mutates state, or setting a variable by hand, changes the program you are observing.

Example One session on the cart example, from the first breakpoint to the diagnosis:

  1. Set a line breakpoint on total += pending.remove(0).price();, then run the code that adds three items and calls computeTotal().
  2. At the first pause the variables view shows pending holding all three items, and nothing yet looks wrong.
  3. evaluate expression on pending == items answers true. That single evaluation is the diagnosis — the list being emptied is the cart's own — and it cost no edit, no rebuild, and no re-run.
  4. The call stack shows computeTotal() called from the display code, so the frame the failure will surface in is not the frame the defect is in.
  5. Resume, and watch items.size() fall in the variables view as the loop runs.

A field watchpoint on items would not have helped here: items is assigned once, where it is declared, so the watchpoint fires at construction and never for remove(0). A watchpoint catches a field being reassigned, not the object it already points at being modified.

Set your first breakpoint before the suspected region rather than at the failure, so you can watch the state go wrong.

AI assistants are useful for some parts of debugging and unreliable for others. They are good at explaining unfamiliar error messages, proposing candidate hypotheses, and serving as an always-available rubber duck. They are unreliable at diagnosing a defect in code they cannot run, and will produce confident, fluent, incorrect explanations. A systematic method is what makes them safe: treat any suggestion as a hypothesis, insist it be falsifiable, and verify it against the running program yourself.

Reading stack traces

A stack trace is a precise report of where a program failed and the call path that led there. Read it in this order:

  1. The exception type and message, which frequently name the problem outright.
  2. The topmost frame in your code — not the topmost frame overall, which is usually library or platform code doing exactly what it was asked.
  3. The chain of callers below it, which shows how execution arrived there.

The top of the trace is where the failure surfaced, but the cause is often further down, in whichever frame passed the bad value along. In wrapped exceptions, read the Caused by: chain from the bottom up.

Some common Java exceptions and what they usually mean:

Exception Usually means
NullPointerException Something never initialized, or a method returning null unnoticed
IndexOutOfBoundsException An off-by-one, or an index computed from stale size information
ClassCastException An object that is not the type assumed, often after an unchecked cast
ConcurrentModificationException A collection modified while being iterated over, usually in a single thread Example removing from a list inside a for-each loop over that list
StackOverflowError Recursion with a missing or unreachable base case
NumberFormatException Unvalidated input being parsed as a number

W6.4d

Implementation → Debugging → Fixing

Do not settle on a fix until you can explain the whole failure. Temporary changes made as experiments are fine, but a change you intend to keep needs a causal account that explains every observed behavior. The strongest check is to predict, before making the change, exactly what will be different afterwards, then verify that prediction. A fix that works for reasons you cannot state can fail again.

Fix the cause, not the infection and not the failure. Special-casing the failing input or clamping a bad value removes the symptom and leaves the cause. Also consider whether you have a coding error or a design error: a coding error is code that does not do what you intended -- whereas a design error means the intention itself was wrong. The real remedy for the latter is a design change, not a code fix.

Which change counts as 'the fix' is sometimes a genuine choice. Making that choice consciously helps ensure that the fix does not degrade other aspects of the system (e.g., its design integrity).
Example In the cart example you could make getItems() return a copy, or make computeTotal() iterate without mutating. Both remove the failure; they differ in which contract you treat as authoritative.

Once you have a candidate fix, finish the job:

  • Look for the defect's relatives — the same mistake was probably made in the sibling method, the other branch, or the block copy-pasted from this one.
  • Verify against the reproduction, then run the full test suite. A fix that resolves your failure while breaking two other things is not a fix.
  • Add a regression test that fails before the fix and passes after it. If you automated the reproduction earlier, you already have it.
  • Remove your temporary probes — stray print statements, leftover , commented-out experiments. Assertions and logging you added deliberately to stay are not temporary probes; keep those.
  • Commit the fix on its own, apart from unrelated cleanup, so that the history stays bisectable for the next bug.

Example The cart example, end to end:

  1. Track: computing a total must not change the cart's contents; filed as "cart empties itself after the total is shown".
  2. Reproduce: adding three items and then calling computeTotal() empties the cart every time.
  3. Automate and simplify: a test that adds items, calls computeTotal(), then asserts on getItems().size(). One item is enough to fail, so the test uses one.
  4. Find origins, Focus, Isolate: the only statement that could empty items is the loop in computeTotal(), and size() dropping from 3 to 0 across the call — with pending and items confirmed to be the same object — settles it.
  5. Correct: both getItems() returning a copy and computeTotal() iterating without mutating would remove the failure. Choose one and implement it.
    Check the relatives: any other getter on the class that returns an internal collection has the same problem.
    Wrap up: The test now passes, the rest of the suite still passes, the new test stays behind as the regression test, and the breakpoints used can come out.

W6.4e : OPTIONAL

Implementation → Debugging → Advanced



Follow up notes for the item(s) above:

AI's impact on » Debugging

AI can help with the grunt work of debugging, but being skilled in systematic debugging still matters. Agents can explain error messages, inspect unfamiliar code, suggest possible causes, and generate tests or probes, so routine searching and debugger setup matter less. However, an agent can confidently give a wrong diagnosis or change code until the symptom disappears without finding the cause.

As developers debug more code they did not write, they need good debugging skills to be able to reproduce failures, test hypotheses against the running program, and verify that each fix removes the real cause.

Guidance for the item(s) below:

AI's impact on » IDEs

AI changes what you use an IDE for, not whether you need one.

  • The features that help you write code matter less now. Auto-completion, code snippets, and memorized shortcuts save less time when the code arrives from an agent all at once. Typing code fast is no longer what makes a developer fast.
  • The features that help you read and check code matter more. Jumping to a definition, finding every place a method is used, stepping through a debugger, reading a diff before you accept it: this is how you check work you did not write. An agent can tell you what its code is meant to do. Only running it will tell you what it actually does.
  • So an IDE is turning into a place where you inspect code, not just a place where you write it. That makes the navigation and debugging features worth learning well, even if an agent does most of the typing.

[W6.5] IDEs: Intermediate Features

W6.5a

Tools → IDEs → Debugging

Refer to these se-edu guides:


W6.5b

Tools → IDEs → Code navigation