How do I apply the composition-over-inheritance pattern in EK9?

← Design Patterns and Idioms · Ref: Q212

EK9 types are closed by default, encouraging composition over inheritance. Wrap a collection or type as a private field and expose only the operations you need.

COMPOSITION PATTERN

Wrap a collection as a private field:

  TaskQueue
    items as List of String

Expose only the methods that make sense:

  addTask(), pendingCount(), hasWork()

MULTI-CONCERN COMPOSITION

Composition shines when wrapping multiple concerns. ValidatedConfig wraps a Dict and adds validation:

  ValidatedConfig
    entries as Dict of (String, String)
    get() with default, set() with validation, has() for existence

The Dict is hidden behind a controlled API that enforces business rules.

WHY COMPOSITION

Built-in types like List and Dict are closed (cannot be extended). This prevents mixing collection behavior with application logic. Composition gives you control over the public API.

WHEN INHERITANCE IS APPROPRIATE

Inheritance is still correct for genuine IS-A relationships. Use 'as open' on the base class and extend when the subtype truly specializes the base.

CONTROLLED INTERFACE

With composition, clients see only:

  config.get(key), config.set(key, value), config.has(key)

Not the full Dict API (merge, replace, iterate, etc.).

See Q101 for closed by default. See Q128 for closed collections. See Q109 for composition. See Q210 for trait delegation. See Q264 for adapter pattern using composition. See Q266 for cross-cutting concerns via delegation. See Q278 for why AI extends closed types. See Q315 for inheritance depth limits that encourage composition.

Example

defines module qa.patterns.composition

  defines class

    TaskQueue
      items as List of String: List() of String

      default TaskQueue()

      addTask()
        -> task as String
        items += task

      pendingCount() as pure
        <- rtn as Integer: length items

      hasWork() as pure
        <- rtn as Boolean: length items > 0

      default operator ?

    // Multi-concern composition: Dict + validation
    ValidatedConfig
      entries as Dict of (String, String): Dict() of (String, String)

      default ValidatedConfig()

      get()
        -> key as String
        <- rtn as String: entries.getOrDefault(key, "")

      set()
        ->
          key as String
          setting as String
        <- rtn as Boolean: false
        if key? and setting?
          entries += DictEntry(key, setting)
          rtn: true

      has() as pure
        -> key as String
        <- rtn as Boolean: entries contains key

      entryCount() as pure
        <- rtn as Integer: length entries

      default operator ?

  defines program

    CompositionDemo()
      stdout <- Stdout()

      // === TASK QUEUE: LIST COMPOSITION ===

      queue <- TaskQueue()
      queue.addTask("Build feature")
      queue.addTask("Write tests")
      queue.addTask("Review code")

      stdout.println(`Pending tasks: ${queue.pendingCount()}`)
      stdout.println(`Has work: ${queue.hasWork()}`)

      // === VALIDATED CONFIG: DICT COMPOSITION ===

      config <- ValidatedConfig()
      hostName <- "host"
      config.set(hostName, "localhost")
      config.set("port", "8080")

      stdout.println(`Has host: ${config.has(hostName)}`)
      stdout.println(`Host: ${config.get(hostName)}`)
      stdout.println(`Entries: ${config.entryCount()}`)

      // Clients use get/set/has, not the full Dict API
      stdout.println("Composition controls the public API")

Common mistakes

E50060 — Dict does not have a get() method. Use getOrDefault() with a fallback value. Calling a non-existent method triggers E50060 — method not resolved. See ek9 -h E50060 for details.

Incorrect:

rtn as String: entries.get(key)

Correct:

rtn as String: entries.getOrDefault(key, "")
Other ways to ask this
  • Why does EK9 prefer composition over inheritance?
  • How do I wrap a collection in EK9?
  • How do I encapsulate a List in EK9?

Coming from another language?

Java: ArrayList<T> is open, commonly extended (bad practice). Python: list is open. Rust: no inheritance, composition by default. Go: embedding for delegation. Kotlin: classes final by default like EK9. EK9: types closed by default, composition is the natural and preferred pattern.

Keywords: closed, migrate, delegate, wrap, inheritance, encapsulate, idiom, design, composition, pattern