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
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:
- Employment IDs are now strongly typed
- Amount worked is typed as a Duration and converted to hours when needed
- The type 'Name' is constrained to match a regular expression
- The type of 'role' has been migrated from plain String values to an Enumeration and called 'RoleName'
- The 'I' has been dropped from traits
- Each part of the 'data store' has been modelled as a 'DB' trait to enable alternative implementations
- 'Role', 'PayrollCalculator', 'PayrollPolicy' and its subclasses have only had minor changes
- 'Employee' has been pulled apart! Now 'Person', 'Employee' and 'Employment' are used in composition
- The employee data, role data, address data and salary policies have all been pulled into separate 'in memory databases'
- 'EmployeeDatabase' has been replaced by 'InMemoryEmployeeDB'
Other simplifications:
- 'ProductivitySystem' only tracks amounts worked
- 'PayrollSystem' only calculates payroll
- Several large methods have been decomposed
Components
The new items are components and applications:
- Use of 'Stdout' being hardwired has been replaced by using an 'OutputInterface' component
- There are two concrete implementations of this 'OutputInterface'
- The concept of an 'HRSystem' has been introduced in abstract form
- A 'SimpleHRSystem' concrete implementation has been provided
- The 'EmployeeDB' the 'SimpleHRSystem' has to use is provided at construction (inversion of control)
- Business level type methods have been provided in the 'HRSystem' and the 'OutputInterface' is provided for output
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.
- Inject 'Factories', 'Strategies' and other major subsystems (like the 'HRSystem').
- Keep that injection as high in the code structure as you can.
- Pass items to low-level utility functions and classes at construction or in method calls.
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:
- InputSanitizer — the core threat detection class. Its threats include SQL injection, XSS, command injection, path traversal, XXE and SSTI attacks.
- SanitizationContext — a record holding rich security event metadata.
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.