Components in EK9

Components go beyond Composition: they add structural constructs that can be injected. They are worth adding only once the software reaches a 'reasonable' size.

From here the site moves on from EK9 syntax to design, because these constructs are all about design, architecture and software engineering. Before the example source, look at this 'tag cloud' and see whether you can tell what the example is about.

TAG Cloud

Tags

Reason the TAG Cloud was shown first

You can see 'string', 'private' and 'println', but most of the cloud is the problem domain: meaningful names that leap out and tell you at a glance that the example is about business, work, employees and salaries.

Why EK9 does it this way. Strong typing is not only about reliable code and fewer invalid parameters; its value is first and foremost for the human reader. Many programming languages lose this point: short, terse, sometimes obfuscated code may save keystrokes, but it hides the main reason for the code. Strong types, traits and well named abstractions make code more flexible, safer, extensible and testable, and they give you a mile high overview of the code that follows.

To show components, the example adds a good deal of functionality to the one in composition. It assumes the software must be very extensible, so many of its classes and components are replaceable. In real development, making every part of a solution replaceable is probably unwise: it leads to naming 'overload'.

Abstractions have value, but they also increase cognitive load: a developer who must dereference types and work more abstractly carries more in their head.

Quality Enforcement: the compiler applies its quality limits to component methods: cyclomatic complexity at most 45, cognitive complexity 35, nesting depth 6, and 100 statements per method (100 for a service operation). Variable and parameter names must be descriptive. Components have higher thresholds than classes: LCOM4 cohesion at most 10 (8 for classes) and coupling (Ce) at most 15 (12 for classes), because components are service entry points and need more coupling. The compiler also detects a class that should be a component (E11022), one that mixes data and service coordination (E11024, E11025), and manual delegation that should use 'by' (E11023). See Code Quality for the full list.

Composition background

Components and applications become useful only when software is a lot larger than this and needs to be much more flexible.

Compare the code with the composition example:

Other simplifications:

Components

The new items are components and applications:

Application

'DemoApp' does only the 'wiring': which classes and components are used together. If you have used 'Spring', it is similar. EK9 aims to keep the wiring to a minimum and inject only at the highest level, which is why only components can be injected; injecting a class is an error (E08160).

Why EK9 does it this way. Components are constrained to be vessels for composition, less flexible and useful than classes, and classes cannot be injected. That reduces the opportunities to 'abuse' Dependency Injection.

Injecting everywhere makes your code less reusable and testable, and harder to refactor.

The Program

This is the point of all this. The original program held the business logic and also decided which parts would carry it out. The new program decides only to use 'DemoApp' (an abstraction), and is unlinked from the details of what is plugged in where.

The program 'Demonstration' has 'HRSystem' and 'OutputInterface' injected, so it only needs to focus on the business logic. That is typical of the 'Single Responsibility Principle': the code is a little longer, but much clearer about its types and about which component implements what.

#!ek9
defines module site.components.hrsystem

  defines type

    EmployeeId as Integer constrain
      > 0

    Name as String constrain as
      matches /^[a-zA-Z -]+$/

    RoleName
      manager
      secretary
      sales
      factory

  defines record

    Address
      street <- String()
      street2 <- String()
      city <- String()
      state <- String()
      zipcode <- String()

      default Address()

      Address()
        ->
          street as String
          street2 as String
          city as String
          state as String
          zipcode as String
        this.street: street
        this.street2: street2
        this.city: city
        this.state: state
        this.zipcode: zipcode

      override operator ? as pure
        <- rtn as Boolean: street? and city? and state? and zipcode?

      operator $ as pure
        <- rtn as String: street2? <- `${street}\n${street2}\n${city} ${state} ${zipcode}` else `${street}\n${city} ${state} ${zipcode}`

  defines trait

    Role
      performDuties()
        -> hours as Integer
        <- activity as String?

      override operator ? as pure
        <- rtn as Boolean: true

    PayrollCalculator
      resetHoursWorked()

      trackWork()
        -> hours as Integer

      calculatePayroll()
        <- amount as Money?

      override operator ? as pure
        <- rtn as Boolean: true

    RoleDB
      findRole()
        -> roleName as RoleName
        <- rtn as Role?

    AddressDB
      findEmployeeAddress()
        -> employeeId as EmployeeId
        <- rtn as Address?

      addEmployeeAddress()
        ->
          employeeId as EmployeeId
          address as Address

    PayrollPolicyDB
      findPayrollPolicy()
        -> employeeId as EmployeeId
        <- rtn as PayrollPolicy?

      addPayrollPolicy()
        ->
          employeeId as EmployeeId
          payrollPolicy as PayrollPolicy

    EmployeeDB
      employed()
        <- employedStaff as List of Employment?

      changeRole()
        ->
          employeeId as EmployeeId
          newRole as RoleName

      addEmployee()
        ->
          person as Person
          role as RoleName
          payrollPolicy as PayrollPolicy

  defines class

    PayrollPolicy with trait of PayrollCalculator as abstract
      hoursWorked <- 0

      override resetHoursWorked()
        hoursWorked = 0

      override trackWork()
        -> hours as Integer
        hoursWorked += hours

      hoursWorked()
        <- hours as Integer: hoursWorked

      override operator ? as pure
        <- rtn as Boolean: hoursWorked?

    HourlyPolicy extends PayrollPolicy
      hourRate <- Money()

      HourlyPolicy()
        -> hourRate as Money
        this.hourRate = hourRate

      override calculatePayroll()
        <- amount as Money: hourRate * hoursWorked()

      default operator ?

    SalaryPolicy extends PayrollPolicy as open
      weeklySalary <- Money()

      SalaryPolicy()
        -> weeklySalary as Money
        this.weeklySalary = weeklySalary

      override calculatePayroll()
        <- amount as Money: weeklySalary

      default operator ?

    CommissionPolicy extends SalaryPolicy
      commissionPerSale <- Money()

      CommissionPolicy()
        ->
          weeklySalary as Money
          commissionPerSale as Money
        super(weeklySalary)
        this.commissionPerSale = commissionPerSale

      override calculatePayroll()
        <- amount as Money: super.calculatePayroll() + commission()

      private commission()
        <- amount as Money: commissionPerSale/5.0 * hoursWorked()

      default operator ?

    ManagerRole with trait of Role
      override performDuties()
        -> hours as Integer
        <- activity as String: `screams and yells for ${hours} hours.`

    SecretaryRole with trait of Role
      override performDuties()
        -> hours as Integer
        <- activity as String: `does paperwork for ${hours} hours.`

    SalesRole with trait of Role
      override performDuties()
        -> hours as Integer
        <- activity as String: `expends ${hours} hours on the phone.`

    FactoryRole with trait of Role
      override performDuties()
        -> hours as Integer
        <- activity as String: `manufactures gadgets for ${hours} hours.`

    GeneralRole with trait of Role
      override performDuties()
        -> hours as Integer
        <- activity as String: `general activities for ${hours} hours.`

    Person
      name <- Name()
      address <- Address()

      Person()
        ->
          name as Name
          address as Address
        this.name: name
        this.address: address

      name()
        <- rtn as Name: name

      address()
        <- rtn as Address: address

      override operator ? as pure
        <- rtn as Boolean: name? and address?

      operator $ as pure
        <- rtn as String: $name

    Employee
      id <- EmployeeId()
      person <- Person()

      Employee()
        ->
          id as EmployeeId
          person as Person
        require id? and person?
        this.id: id
        this.person: person

      id()
        <- rtn as EmployeeId: id

      person()
        <- rtn as Person: person

      override operator ? as pure
        <- rtn as Boolean: id? and person?

      operator $ as pure
        <- rtn as String: `${id} - ${person}`

    Employment with trait of Role by role, PayrollCalculator by payroll
      employee <- Employee()
      role as Role: GeneralRole()
      payroll as PayrollCalculator: HourlyPolicy(Money())

      Employment()
        ->
          employee as Employee
          role as Role
          payroll as PayrollCalculator
        require employee? and role? and payroll?
        this.employee: employee
        this.role: role
        this.payroll: payroll

      changeRole()
        -> newRole as Role
        role: newRole

      work()
        -> amountWorked as Duration
        <- dutiesPerformed as String?
        //Note that duration is converted to hours from this point on.
        require amountWorked.hours() >= 0

        dutiesPerformed: performDuties(amountWorked.hours())
        payroll.trackWork(amountWorked.hours())

      employeeAddress()
        <- rtn as Address: employee.person().address()

      employee()
        <- rtn as Employee: employee

      override operator ? as pure
        <- rtn as Boolean: role? and payroll?

      operator $ as pure
        <- rtn as String: $employee

    InMemoryRoleDB with trait of RoleDB
      roles as Dict of (RoleName, Role): {
        RoleName.manager: ManagerRole(),
        RoleName.secretary: SecretaryRole(),
        RoleName.sales: SalesRole(),
        RoleName.factory: FactoryRole()
        }

      override findRole()
        -> roleName as RoleName
        <- rtn as Role?
        rtn: roles.getOrDefault(roleName, ManagerRole())

      default operator ?

    InMemoryAddressDB with trait of AddressDB
      employeeAddresses as Dict of (EmployeeId, Address): {
        EmployeeId(1): Address(street: "121 Admin Rd.", street2: String(), city: "Concord", state: "NH", zipcode: "03301"),
        EmployeeId(2): Address(street: "67 Paperwork Ave", street2: String(), city: "Manchester", state: "NH", zipcode: "03101"),
        EmployeeId(3): Address(street: "15 Rose St", street2: "Apt. B-1", city: "Concord", state: "NH", zipcode: "03301"),
        EmployeeId(4): Address(street: "39 Sole St.", street2: String(), city: "Concord", state: "NH", zipcode: "03301"),
        EmployeeId(5): Address(street: "99 Mountain Rd.", street2: String(), city: "Concord", state: "NH", zipcode: "03301")
        }

      override findEmployeeAddress()
        -> employeeId as EmployeeId
        <- rtn as Address: Address()
        rtn: employeeAddresses.getOrDefault(employeeId, Address())

      override addEmployeeAddress()
        ->
          employeeId as EmployeeId
          address as Address
        employeeAddresses += DictEntry(employeeId, address)

      default operator ?

    InMemoryPayrollPolicyDB with trait of PayrollPolicyDB
      employeePayrollPolicies as Dict of (EmployeeId, PayrollPolicy): {
        EmployeeId(1): SalaryPolicy(3000#USD),
        EmployeeId(2): SalaryPolicy(1500#USD),
        EmployeeId(3): CommissionPolicy(1000#USD, 100#USD),
        EmployeeId(4): HourlyPolicy(15#USD),
        EmployeeId(5): HourlyPolicy(9#USD)
        }

      override findPayrollPolicy()
        -> employeeId as EmployeeId
        <- rtn as PayrollPolicy?
        rtn: employeePayrollPolicies.getOrDefault(employeeId, HourlyPolicy(9#USD))

      override addPayrollPolicy()
        ->
          employeeId as EmployeeId
          payrollPolicy as PayrollPolicy
        employeePayrollPolicies += DictEntry(employeeId, payrollPolicy)

      default operator ?

    InMemoryEmployeeDB with trait of EmployeeDB, RoleDB by roleDB, AddressDB by addressDB, PayrollPolicyDB by payrollPolicyDB
      roleDB as RoleDB?
      addressDB as AddressDB?
      payrollPolicyDB as PayrollPolicyDB?

      employedStaff as Dict of (EmployeeId, Employment): Dict()
      nextEmployeeId as EmployeeId: EmployeeId(6)

      default private InMemoryEmployeeDB()

      InMemoryEmployeeDB()
        ->
          roleDB as RoleDB
          addressDB as AddressDB
          payrollPolicyDB as PayrollPolicyDB
        this.roleDB: roleDB
        this.addressDB: addressDB
        this.payrollPolicyDB: payrollPolicyDB
        populateEmployedStaff()

      private populateEmployedStaff()
        createEmployment(EmployeeId(1), Name("Mary Poppins"), RoleName.manager)
        createEmployment(EmployeeId(2), Name("John Smith"), RoleName.secretary)
        createEmployment(EmployeeId(3), Name("Kevin Bacon"), RoleName.sales)
        createEmployment(EmployeeId(4), Name("Jane Doe"), RoleName.factory)
        createEmployment(EmployeeId(5), Name("Robin Williams"), RoleName.secretary)

      override employed()
        <- staff as List of Employment: List()
        values <- this.employedStaff.values()
        cat values > staff

      override changeRole()
        ->
          employeeId as EmployeeId
          newRole as RoleName
        if employedStaff contains employeeId
          employment <- employedStaff.getOrDefault(employeeId, Employment())
          employment.changeRole(findRole(newRole))

      override addEmployee()
        ->
          person as Person
          role as RoleName
          payrollPolicy as PayrollPolicy
        newEmployeeId <- nextEmployeeId()

        addEmployeeAddress(newEmployeeId, person.address())
        addPayrollPolicy(newEmployeeId, payrollPolicy)

        employment <- Employment(Employee(newEmployeeId, person), findRole(role), payrollPolicy)
        addEmployment(employment)

      private nextEmployeeId()
        <- id as EmployeeId: EmployeeId()
        id :=: nextEmployeeId
        nextEmployeeId++

      private createEmployment()
        ->
          id as EmployeeId
          name as Name
          theRole as RoleName
        address <- findEmployeeAddress(id)
        employeeRole <- findRole(theRole)
        payrollPolicy <- findPayrollPolicy(id)
        addEmployment(Employment(Employee(id, Person(name, address)), employeeRole, payrollPolicy))

      private addEmployment()
        -> employment as Employment
        employedStaff += DictEntry(employment.employee().id(), employment)

      default operator ?

    ProductivitySystem with trait of StringOutput by output
      output as StringOutput?

      default private ProductivitySystem()

      ProductivitySystem()
        -> output as StringOutput
        this.output: output

      track()
        ->
          employed as List of Employment
          amountWorked as Duration
        println("Tracking Employee Productivity")
        println("==============================")
        for employment in employed
          dutiesPerformed <- employment.work(amountWorked)
          println(`Employee ${employment}: `)
          println(" - " + dutiesPerformed)
          println("")
        println("")

      default operator ?

    PayrollSystem with trait of StringOutput by output
      output as StringOutput?

      default private PayrollSystem()

      PayrollSystem()
        -> output as StringOutput
        this.output: output

      calculatePayroll()
        -> employed as List of Employment
        println("Calculating Payroll")
        println("===================")
        for employment in employed
          println("Payroll for: " + $employment)
          println("- Check amount: " + $employment.calculatePayroll())
          if employment.employeeAddress()?
              println("- Sent to:")
              println($employment.employeeAddress())
          println("")

      default operator ?

  defines component

    OutputInterface as abstract
      output()
        <- outputTo as StringOutput: Stdout()

    StdoutInterface is OutputInterface

    StderrInterface is OutputInterface
      override output()
        <- outputTo as StringOutput: Stderr()

    HRSystem as abstract
      newWorkingWeek() as abstract

      calculatePayroll() as abstract
        -> outputInterface as OutputInterface

      trackWork() as abstract
        ->
          outputInterface as OutputInterface
          amountWorked as Duration

      changeRole() as abstract
        ->
          employeeId as EmployeeId
          newRole as RoleName

      newStarter() as abstract
        ->
          person as Person
          role as RoleName
          payrollPolicy as PayrollPolicy

    SimpleHRSystem is HRSystem
      employeeDb as EmployeeDB?

      default private SimpleHRSystem()

      SimpleHRSystem()
        -> employeeDb as EmployeeDB
        this.employeeDb: employeeDb

      override newWorkingWeek()
        employed <- employeeDb.employed()
        for employment in employed
          employment.resetHoursWorked()

      override calculatePayroll()
        -> outputInterface as OutputInterface
        payrollSystem <- PayrollSystem(outputInterface.output())
        payrollSystem.calculatePayroll(employeeDb.employed())

      override trackWork()
        ->
          outputInterface as OutputInterface
          amountWorked as Duration
        productivitySystem <- ProductivitySystem(outputInterface.output())
        productivitySystem.track(employeeDb.employed(), amountWorked)

      override changeRole()
        ->
          employeeId as EmployeeId
          newRole as RoleName
        require employeeId? and newRole?
        employeeDb.changeRole(employeeId, newRole)

      override newStarter()
        ->
          person as Person
          role as RoleName
          payrollPolicy as PayrollPolicy
        require person? and role? and payrollPolicy?
        employeeDb.addEmployee(person, role, payrollPolicy)

      default operator ?

  defines application

    //Separation of what components/classes to use rather than what to do with them
    DemoApp
      employeeDb <- InMemoryEmployeeDB(InMemoryRoleDB(), InMemoryAddressDB(), InMemoryPayrollPolicyDB())

      register SimpleHRSystem(employeeDb) as HRSystem
      register StdoutInterface() as OutputInterface

  defines program

    //Separation of what functionality to provide with configuration supplied.
    Demonstration() with application of DemoApp
      hrSystem as HRSystem!
      outputInterface as OutputInterface!

      hrSystem.trackWork(outputInterface, PT40H)
      hrSystem.calculatePayroll(outputInterface)

      hrSystem.newWorkingWeek()
      //Short week.
      hrSystem.trackWork(outputInterface, PT32H)
      hrSystem.calculatePayroll(outputInterface)

Running the program 'Demonstration' tracks two working weeks (the second a short one) and pays everyone for each:

Tracking Employee Productivity
==============================
Employee 1 - Mary Poppins: 
 - screams and yells for 40 hours.

Employee 2 - John Smith: 
 - does paperwork for 40 hours.

Employee 3 - Kevin Bacon: 
 - expends 40 hours on the phone.

Employee 4 - Jane Doe: 
 - manufactures gadgets for 40 hours.

Employee 5 - Robin Williams: 
 - does paperwork for 40 hours.


Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000.00#USD
- Sent to:
121 Admin Rd.
Concord NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500.00#USD
- Sent to:
67 Paperwork Ave
Manchester NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1800.00#USD
- Sent to:
15 Rose St
Apt. B-1
Concord NH 03301

Payroll for: 4 - Jane Doe
- Check amount: 600.00#USD
- Sent to:
39 Sole St.
Concord NH 03301

Payroll for: 5 - Robin Williams
- Check amount: 360.00#USD
- Sent to:
99 Mountain Rd.
Concord NH 03301

Tracking Employee Productivity
==============================
Employee 1 - Mary Poppins: 
 - screams and yells for 32 hours.

Employee 2 - John Smith: 
 - does paperwork for 32 hours.

Employee 3 - Kevin Bacon: 
 - expends 32 hours on the phone.

Employee 4 - Jane Doe: 
 - manufactures gadgets for 32 hours.

Employee 5 - Robin Williams: 
 - does paperwork for 32 hours.


Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000.00#USD
- Sent to:
121 Admin Rd.
Concord NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500.00#USD
- Sent to:
67 Paperwork Ave
Manchester NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1640.00#USD
- Sent to:
15 Rose St
Apt. B-1
Concord NH 03301

Payroll for: 4 - Jane Doe
- Check amount: 480.00#USD
- Sent to:
39 Sole St.
Concord NH 03301

Payroll for: 5 - Robin Williams
- Check amount: 288.00#USD
- Sent to:
99 Mountain Rd.
Concord NH 03301

The example adds some functionality, but its main point is to make the whole system pluggable. Some developers will call this 'over engineering'; whether it is depends on how large the software is and how long it is meant to last.

There are two parallel tracks: the abstract design (interfaces/traits), and the concrete implementations of those interfaces/traits. The important part is that there is decoupling everywhere.

Decoupling has a downside: deep inside a method, the code only 'knows' it has an object that conforms to an interface/trait. But that is the point. You cannot 'cast' it or use instanceof, because those concepts do not exist in EK9; you could use a dispatcher method.

Discussion

There is significantly more code than in the previous example, beyond the new functionality. Most of it defines the traits and separates out Single Points of Responsibility; the stronger typing of 'role name', 'employee id' and 'name' adds some more.

Look at the 'tag cloud' again: there is value here. By contrast, you could hold the 'Address' as a 'List of String'. But then it is only a 'List of String'; it has not been abstracted into anything meaningful in the problem domain.

EK9 lets you code abstractly, with lots of decoupling, or with little abstraction at all. Find the right blend of abstraction for your project or product: its lifetime, use and size, and your team's experience.

Summary

Components seem limited compared with classes, and even traits. That is by design: they are an enabler for Dependency Injection and Aspect Oriented Programming. Those are advanced topics with limited, specific roles, but when you need to deal with many crosscutting concerns, use components.

Why EK9 does it this way. Some architects and developers value AOP; others believe it adds complexity and makes code more fragile. EK9's AOP is simple: it tells you, coarsely, that a specific method on a component was called. There is no fine-grained selection of specific methods by name, because that is fragile and breaks when code is refactored. Use AOP with care; when you need fine-grained detail, use classes and traits with composition and delegation.

Built-in Components

The standard library does not define any components today. Security, for example, is handled by the InputSanitizer class rather than by components, which keeps the approach simple and unified:

The EK9_SANITIZER_LOG_FORMAT environment variable controls the log format (JSON, ECS, CEF, SIMPLE or SILENT). See Input Sanitization for the full list of threats and details.

Next Steps

The next step in architectural macrostructure continues with components and adds Dependency Injection and Aspect Oriented Programming.