1

Your First Sequence Diagram

Why this matters

Class diagrams show what exists in a system; sequence diagrams show what happens at runtime — which object calls which method, in what order. As soon as you start designing or debugging real interactions (logins, API handshakes, message flows), you need a way to describe behavior over time, not just structure. This first step gives you the smallest complete sequence diagram and shows you how Python code on the page becomes a picture you can read.

🎯 You will learn to

  • Apply the lifeline notation by identifying participants in a sequence diagram
  • Create Python code that produces synchronous messages between two object instances

Where Class Diagrams End, Sequence Diagrams Begin

You already know class diagrams — they show what exists: classes, attributes, methods, relationships. A sequence diagram shows what happens at runtime: which object calls which method, and in what order.

Think of it as the difference between a floor plan (class diagram) and a security camera recording (sequence diagram). Same building, very different question.

Four Pieces of Notation

Element What it looks like What it means
Participant (lifeline) A box at the top, with a dashed line below A specific object instance active during the scenario
Synchronous message Solid arrow with a filled arrowhead → One object calls a method on another, and waits for it to finish
Activation box A thin rectangle on the lifeline The object is currently executing — a call stack frame in memory
Time Top-to-bottom Earlier events are higher up; later events are lower

Key distinction: A lifeline is not a class. bot: DiscordBot means “this particular bot instance”. If your code creates two bots, you get two lifelines — even though there is only one DiscordBot class.

A Simpler Example First

Here is a minimal diagram — a user object calls login() on an auth object:

Two lifelines, one synchronous call. That is a complete sequence diagram. Read the arrow as a sentence: “user calls login(password) on auth, and waits for it to finish.”

Your Target Diagram

Now let us build one together. Write Python code until the live Sequence Diagram panel matches this target:

Reading the target:

  • Main is the script itself — any code outside a class or function (specifically, the body of if __name__ == "__main__":) becomes a synthetic lifeline labeled Main. You didn’t declare it; the analyzer did, to represent “whoever is starting the scenario.”
  • bot: DiscordBot is a specific bot instance created by bot = DiscordBot()
  • channel: Channel is a specific channel instance
  • The two dashed <<create>> arrows appear because Main constructs each object
  • The two solid arrows are synchronous calls — Main calls send(...) on bot, then notify_members(...) on channel

Note — Main is a learning scaffold, not real-world practice. In this tutorial every diagram starts from __main__, giving you a concrete Python anchor for every arrow. Professional sequence diagrams almost never do this. A real diagram focuses on a specific interaction between objects that are already alive — it picks up the story at an interesting method call and does not trace from program startup. You would not see a Main lifeline in a diagram drawn on a whiteboard during a design meeting; instead you might see user, authService, and database — all assumed to exist — with the scenario beginning at user -> authService: login(password). The Main lifeline is here purely to make Python execution explicit while you are learning the notation.

Your Task

The file step1/chatbot.py already defines DiscordBot and Channel. Your job is to write the if __name__ == "__main__": block so it:

  1. Creates a DiscordBot instance called bot
  2. Creates a Channel instance called channel
  3. Calls bot.send("Hello, world!")
  4. Calls channel.notify_members("Welcome")

Watch the Sequence Diagram panel — it updates live as you type!

Heads up: Variable names become participant names. If you write dbot = DiscordBot() instead of bot = DiscordBot(), the diagram will show dbot: DiscordBot. Pick meaningful names — they end up in the picture.

Starter files
step1/chatbot.py
class DiscordBot:
    def send(self, message):
        print(f"[BOT] {message}")


class Channel:
    def notify_members(self, message):
        print(f"[CHANNEL] {message}")


if __name__ == "__main__":
    # Your task: make the diagram match the target.
    #
    # 1. Create a DiscordBot called `bot`
    # 2. Create a Channel called `channel`
    # 3. Call bot.send("Hello, world!")
    # 4. Call channel.notify_members("Welcome")
    pass
2

Return Values: The Dashed Arrow

Why this matters

Most useful methods give something back — a count, a status, a result — and the diagram has to show those returns without burying the reader in noise. UML draws a dashed return arrow only when the returned value carries information the reader cares about, so you need to recognise the two precise conditions that trigger one. Get this right and your diagrams stay readable; miss it and either important data disappears or trivial returns clutter the picture.

🎯 You will learn to

  • Analyze when a return message appears on a sequence diagram (and when it does not)
  • Apply Python type annotations and assignments to produce a dashed return arrow

The Two Rules for Return Arrows

A return message is drawn as a dashed arrow with an open arrowhead (⇠). It points back from the callee to the caller, at the moment the method finishes.

But here is the catch — sequence diagrams do not draw a return arrow for every call. That would be noise. Instead, two things must be true:

  1. The method has a non-None return type (annotate it: -> int, -> str, etc.)
  2. The caller captures the return value in a variable (count = bot.get_count())

If you just write bot.send("hi") and ignore any return, no dashed arrow appears — because “the call finished and came back” is already implied by the activation box ending. UML only shows returns when they carry information the reader cares about.

Example — With and Without Capture

Without capture — a solid call and an activation box, but no dashed return:

With capture — solid arrow going in, dashed arrow coming back:

Read the dashed arrow as “the method finished and handed back a value of this type.”

Your Target Diagram

Extend the chat bot from Step 1. Now DiscordBot has a method that reports the current member count, and Main captures it to decide what to say:

Notice the new dashed arrow from bot back to Main labeled int — that is the return arrow. The old call to channel.notify_members(...) has no dashed return arrow because its return type is None.

Your Task

Open step2/chatbot.py. The starter code has the method defined, but the __main__ block:

  1. Does not capture the return value of get_member_count() — fix that
  2. Uses a hardcoded string — replace it with an f-string that uses the captured count

Reminder: For the dashed arrow to appear, two things must be true — the method must have a return type annotation (-> int already in the starter), and you must assign the return value to a variable.

Starter files
step2/chatbot.py
class DiscordBot:
    def send(self, message: str) -> None:
        print(f"[BOT] {message}")

    def get_member_count(self) -> int:
        return 5


class Channel:
    def notify_members(self, message: str) -> None:
        print(f"[CHANNEL] {message}")


if __name__ == "__main__":
    bot = DiscordBot()
    channel = Channel()

    # TODO: capture the return value of bot.get_member_count()
    bot.get_member_count()

    # TODO: use the captured count in the notify message
    channel.notify_members("5 members online")
3

Self-Calls and Nested Activation

Why this matters

Real classes rarely expose every detail; they delegate to private helper methods on the same object. When the diagram captures that delegation, you can see at a glance which public method is the orchestrator and which are its internal pieces. Activation boxes are not decoration — they are the literal call stack you already debug every day, drawn vertically. Connecting that mental model to the diagram is the threshold concept of this step.

🎯 You will learn to

  • Analyze why an activation box represents a call stack frame
  • Apply self-message notation to produce nested activation from Python code

The Call Stack, Drawn

You already know the call stack from debugging Python: every time a function calls another function, a new stack frame is pushed; when the function returns, the frame is popped.

A sequence diagram’s activation box is the exact visual of that. When a message arrives at a lifeline, an activation box starts. When the method returns, the box ends.

Mental model: Activation box ≈ stack frame. A method that takes longer has a taller box. A method that calls another method has a nested box stacked on top of its own. (The mapping is close but not perfect — generators, async, and coroutines blur the picture. For 99% of the synchronous code you will write as an undergraduate, “stack frame” is the right intuition.)

Self-Messages

When an object calls a method on itself (self.some_method()), the arrow loops back to the same lifeline — and a new activation box stacks on top of the current one. This is exactly how your Python interpreter works: a recursive or internal call pushes a fresh frame.

Example — A Method That Delegates

Consider an Order object whose checkout() method calls its own _validate() helper:

Notice the arrow from order to itself, and how it sits inside the outer activation box for checkout(). The small nested box is the stack frame for _validate() pushed on top of checkout()’s frame.

Your Target Diagram

In step3/chatbot.py, handle_message() should be a small orchestrator: it calls self._log() and then self.send(), both methods on the same bot. Your target:

Three arrows — one from Main to bot, and two from bot to itself. Visually, the two self-calls are nested inside the handle_message activation box because they happen while that method is still running.

Your Task

The starter file defines DiscordBot with _log() and send() methods, but handle_message() is empty. Your job:

  1. Fill in handle_message() so it calls self._log(message) and then self.send(message)
  2. In __main__, call bot.handle_message("hi there") — and only that

Watch for this: self._log(...) — not _log(...) without the self. prefix. Without self., the call goes to a free function, not a method, and the sequence diagram will not draw the self-arrow. The self. is what tells the analyzer “same object.”

Starter files
step3/chatbot.py
class DiscordBot:
    def _log(self, message: str) -> None:
        print(f"[LOG] received: {message}")

    def send(self, message: str) -> None:
        print(f"[BOT] {message}")

    def handle_message(self, message: str) -> None:
        # TODO: inside this method, call self._log(message)
        #       and then self.send(message).
        #       Both calls should appear as self-arrows in the diagram.
        pass


if __name__ == "__main__":
    bot = DiscordBot()
    # TODO: call bot.handle_message("hi there")
4

Conditional Fragments: opt and alt

Why this matters

Real behavior almost always branches — spam vs. legitimate traffic, cache hit vs. miss, authorised vs. denied. A sequence diagram that only shows a single straight-line trace cannot communicate any of that. The opt and alt interaction fragments are how UML draws conditional execution, and the only difference between them is whether there is an else. Mastering this small contrast lets you turn any Python if statement into the right diagram on the first try.

🎯 You will learn to

  • Analyze when to choose opt vs. alt based on the Python control flow
  • Apply if and if/else to produce each fragment in a sequence diagram

Combined Fragments Are Boxes Around Messages

So far every diagram has been a straight top-to-bottom trace. But real systems branch — sometimes they do X, other times Y. UML handles this with combined fragments: labeled boxes drawn around the messages they contain.

There are two conditional fragment types, and the only difference between them is whether there’s an else:

Fragment Label Python Meaning
opt opt if ... (no else) Zero or one execution — inside runs only if the guard is true
alt alt / else if ... else ... Exactly one branch runs — the guard selects which

Both fragments wrap their region of the diagram in a thin rectangle with a guard condition (the Boolean test) in square brackets in the top-left corner.

Example — An opt Fragment

A bot decides whether to welcome a new member — only if they are not already subscribed. If they are subscribed, nothing happens:

The opt box says: “either this message happens, or nothing does — depending on the guard.” There is no second compartment.

Example — An alt Fragment

A spam filter: if spam, block; otherwise, forward. Two compartments, exactly one runs:

The alt box says: “exactly one of these branches runs.” The guard tells you which.

The choice rule: opt for a single conditional message, alt for mutually-exclusive branches. If your else would be empty, use opt; if both branches do something, use alt. The Python code shape decides for you — which is another reason to keep code and diagram in sync.

Your Target Diagram

The bot has a handle(channel, message) method that:

  • If the message is spam: blocks it via self._block(message).
  • Otherwise: forwards it to the channel via channel.broadcast(message).

That’s a two-way split — an alt.

Your Task

The starter code has handle(channel, message) written with no branching — it unconditionally forwards everything. Your job:

  1. Replace the body with if self._is_spam(message): / else: — produces the alt fragment with two compartments.
  2. In the if branch: call self._block(message).
  3. In the else branch: call channel.broadcast(message).

Note on _is_spam: It is already defined — a trivial classifier. You just need to call it in the if condition. That call itself draws a tiny self-arrow (it’s a real method call) — that is expected.

Starter files
step4/chatbot.py
class Channel:
    def broadcast(self, message: str) -> None:
        print(f"[CHANNEL] {message}")


class DiscordBot:
    def _is_spam(self, message: str) -> bool:
        return "buy now" in message.lower()

    def _block(self, message: str) -> None:
        print(f"[BLOCKED] {message}")

    def handle(self, channel: Channel, message: str) -> None:
        # TODO: rewrite this method so:
        #   - if self._is_spam(message): self._block(message)
        #   - else:                      channel.broadcast(message)
        # That produces the `alt` fragment in the target diagram.
        channel.broadcast(message)


if __name__ == "__main__":
    bot = DiscordBot()
    channel = Channel()
    bot.handle(channel, "buy now cheap")
5

Loops: Doing the Same Thing Many Times

Why this matters

Iteration is in nearly every real interaction — broadcasting to every subscriber, processing each item in a queue, retrying until success. A sequence diagram cannot duplicate the same arrow ten times to mean “this happens for every item”; it uses the loop fragment instead. The visual grammar is identical to opt and alt from Step 4 — a thin rectangle, a keyword, a guard in square brackets — only the meaning changes from pick to repeat. Once you see that pattern, you will recognise every fragment on sight.

🎯 You will learn to

  • Apply for and while loops in Python to produce a loop fragment in the diagram
  • Analyze when the right answer is one fragment vs. multiple smaller diagrams

The loop Fragment

Step 4 taught the two branching fragments (opt and alt). There is one more fragment you will use constantly: loop, for iteration.

Fragment Label Python Meaning
loop loop for / while Contents run zero or more times

The visual grammar is identical to opt and alt — a thin rectangle, a keyword in the top-left, a guard in square brackets. The only thing that changes is the keyword and the meaning: repeat instead of pick.

Example — A loop Fragment

Sending a welcome to every member — the message is sent once per iteration:

The loop box says: “the message(s) inside run once for every item in the collection.” If the collection is empty, the box still appears, but the messages inside run zero times.

Your Target Diagram

The bot has a broadcast_all(channel, messages) method that sends each message in the list to the channel.

Your Task (Fixer-Upper)

The starter code has broadcast_all written as a flat sequence — one unconditional call. That produces one bare arrow in the diagram. Your job:

  1. Replace the single call with for msg in messages: — produces the loop fragment.
  2. Inside the loop, call channel.send_to_all(msg) once per iteration.
Starter files
step5/chatbot.py
class Channel:
    def send_to_all(self, message: str) -> None:
        print(f"[CHANNEL] {message}")


class DiscordBot:
    def broadcast_all(self, channel: Channel, messages: list) -> None:
        # TODO: replace this unconditional call with a loop so the
        # diagram shows a `loop` fragment instead of a single arrow.
        channel.send_to_all(messages[0])


if __name__ == "__main__":
    bot = DiscordBot()
    channel = Channel()
    bot.broadcast_all(channel, ["hi", "hello", "good morning"])
6

Putting It All Together: A Moderated Broadcast

Why this matters

A real sequence diagram is never one notation in isolation — it weaves lifelines, returns, self-calls, and control-flow fragments into a single scenario that tells a story. You have learned every piece already; the difficulty here is integrating them. If you stare at the target diagram for a minute before seeing how it maps to code, that is the point — working developers have the same experience when they first design a real diagram, and the only way to build that fluency is to do it.

🎯 You will learn to

  • Create a Python method whose sequence diagram combines lifelines, a captured return, self-calls, and both alt and loop fragments
  • Analyze a target diagram and predict its code shape before writing a line

The Scenario

The bot runs a daily digest over a list of recent posts. Before the loop starts, it asks the channel how many subscribers it has, so it can log the size of the digest. Then, for each post:

  • Announcements (posts starting with @all) get broadcast to the channel.
  • Everything else is silently skipped — the bot logs the skip but does not bother the channel.

Your Target Diagram

Notice every concept from Steps 1-5 appears:

  • Lifelines and creation (Step 1): Main, bot: DiscordBot, channel: Channel, with two <<create>> arrows.
  • Return value (Step 2): the dashed arrow labeled count: int from channel back to bot after get_subscriber_count() — the generator includes the bound variable name because count is used on the next line.
  • Self-call with nested activation (Step 3): bot -> bot: _log_start and, inside the loop, bot -> bot: _log_skip.
  • Conditional fragment (Step 4): one alt inside the loop.
  • Loop fragment (Step 5): one outer loop over posts.

One loop outside, one alt inside — exactly the two-level nesting limit that Step 5’s quiz warned you not to exceed.

Your Task

Open step6/chatbot.py. The helper methods are already defined (Channel.get_subscriber_count, _is_announcement, _log_start, _log_skip). Your job is to:

  1. Implement run_digest(channel, posts) on DiscordBot so it:
    1. Captures the result of channel.get_subscriber_count() in a local variable.
    2. Calls self._log_start(<that variable>) to announce the digest.
    3. Iterates over posts. For each post:
      • If self._is_announcement(post): call channel.broadcast(post).
      • Otherwise: call self._log_skip(post).
  2. In __main__, create one bot, one channel, and call bot.run_digest(channel, posts) exactly once.

Predict first. Before you start typing, take 30 seconds and mentally walk the diagram: how many lifelines, how many arrows, which are dashed, where does the alt sit relative to the loop? Writing the code after visualising it is much faster than writing code and hoping the diagram matches.

Starter files
step6/chatbot.py
class Channel:
    def broadcast(self, message: str) -> None:
        print(f"[BROADCAST] {message}")

    def get_subscriber_count(self) -> int:
        return 42


class DiscordBot:
    def _is_announcement(self, post: str) -> bool:
        return post.startswith("@all")

    def _log_start(self, count: int) -> None:
        print(f"[DIGEST] starting for {count} subscribers")

    def _log_skip(self, post: str) -> None:
        print(f"[DIGEST] skipped: {post}")

    def run_digest(self, channel: Channel, posts: list) -> None:
        # TODO: implement this method so it matches the target diagram.
        #   1. Capture channel.get_subscriber_count() in a local variable
        #   2. Call self._log_start(<that variable>)
        #   3. for post in posts:
        #        if self._is_announcement(post):
        #            channel.broadcast(post)
        #        else:
        #            self._log_skip(post)
        pass


if __name__ == "__main__":
    posts = [
        "@all staff meeting at 3pm",
        "just saying hi",
        "@all remember to stretch",
    ]
    # TODO: create `bot` and `channel`, then call
    # bot.run_digest(channel, posts) exactly once.
7

Sequence Diagram Reference

Why this matters

Congratulations — you can now read and write basic UML sequence diagrams: lifelines, synchronous calls, return messages, self-calls with nested activation, and the opt / alt / loop fragments. Step 6 proved you can weave them together in one scenario. The notation only sticks if you can pull it back out of memory later, so this page is structured as a self-test first and a cheat sheet second — retrieval before review is what makes the learning durable.

🎯 You will learn to

  • Evaluate your own recall of every notation element introduced in Steps 1–6
  • Apply this reference card as a quick lookup when designing future diagrams

Self-check (close this page first)

Before you scroll to the tables below, try to answer these from memory. Look back only when you are stuck:

  1. What does a lifeline represent — a class, an instance, or a file?
  2. What two conditions must BOTH be true for a dashed return arrow to appear?
  3. Why does a self-call produce a nested activation box?
  4. If your Python method is for x in xs: if x.valid: bot.send(x) (no else), what two fragments appear — and in which order?

Retrieval before review is the learning — just reading the tables again is not.


The Core Pieces

Element Looks like Python that produces it
Lifeline box on top, dashed line below any object instance: bot = DiscordBot()
Activation box thin rectangle on the lifeline a method call — begins when the call arrives, ends when it returns
Synchronous message solid line, filled arrowhead → x.method(...) — caller waits
Return message dashed line, open arrowhead ⇠ y = x.method() and method returns a non-None type and caller ≠ callee
Self-message arrow looping back to the same lifeline self.method(...) inside a method
Creation dashed arrow with <<create>> label to a new lifeline constructor: bot = DiscordBot()

The Three Fragments You Will Use Most

Fragment Meaning Python
opt zero or one execution if ... (no else)
alt choose exactly one branch if ... elif ... else ...
loop repeat zero or more times for / while

Fragments You May Encounter Later

  • par — parallel branches execute concurrently (e.g., asyncio.gather)
  • break — exit the enclosing loop
  • ref — an “interaction use”; a named sub-scenario referenced from another diagram
  • critical — an atomic region
  • neg — an invalid trace (what must not happen)

Arrow Cheat Sheet

  • -> synchronous (caller blocks)
  • --> return (dashed, open arrow)
  • ->> asynchronous (caller keeps going — you will meet this later)
  • -> self self-call

Guidelines You Should Remember

  1. Lifelines are instances, not classes. Two Dog() calls → two lifelines.
  2. Activation boxes are stack frames. They start on the way in, end on the way out. Nested activation = nested calls.
  3. Do not draw every if and for. One or two fragment levels is usually enough — split deeply-branching logic into multiple diagrams.
  4. One scenario per diagram. A sequence diagram answers a single question. Happy path, error path, and edge cases typically belong in separate diagrams.
  5. Only draw return arrows when the value matters. UML is about communication — if the return is None or implied by the activation box ending, skip the dashed arrow.
  6. Real diagrams do not start from Main. In this tutorial every scenario began from __main__ to give you a Python anchor for every arrow. In practice, sequence diagrams focus on a specific interaction between objects that are already running — they start at an interesting method call, not at program startup. A whiteboard diagram might open with user -> authService: login(password) and never show how user or authService were constructed. The Main lifeline was a learning scaffold; leave it behind in your own diagrams.

What Sequence Diagrams Are Good For

  • Designing an interaction before you write the code
  • Explaining a specific scenario to a teammate or reviewer (much faster than prose)
  • Documenting a protocol (API handshake, auth flow, publish/subscribe)
  • Finding a bug — draw the diagram of what you expect vs. what actually happens

And what they are not good for: showing the complete behavior of a system. Use a class diagram for structure and use multiple small sequence diagrams for specific runtime scenarios.

Next up: you now know both halves of UML modeling — structure (class diagrams) and behavior (sequence diagrams). In your software engineering career you will mix and match these constantly, usually on whiteboards, usually for five minutes at a time. That is the sweet spot UML was designed for.

Starter files
step7/README.md
# Sequence Diagram Reference

Nothing to code in this step — it is a summary page.

Use it as a cheat sheet when working on future sequence diagrams.