Scalable Frontend Architecture that meets Your Business Thomas Gossmann - gos.si - @unistyler
A presentation at EmberFest in September 2024 in Dublin, Ireland by Thomas Gossmann
Scalable Frontend Architecture that meets Your Business Thomas Gossmann - gos.si - @unistyler
Architecture Architects: Draw the map and guide engineers to the treasure Engineers: Read the map to reach the treasure
Quiz: What does this Product do? (1)
Quiz: What does this Product do? (2)
Quiz: What does this Product do? (3)
Default Directory Structure - Why ? Good onboarding to the framework Explains technical aspects of the framework Good for hobby and weekend projects Hardly scalable beyond that
PART 1 Meet Your Business Tactical Design
Technical Objects Domain Objects Components Contract Services Appointment Routes Risk Audit not aspects of your product Saloon Calendar they are aspects of your product
Why there is no Domain-Driven Development ? It is hard to do. Some observed reasons: 1. Education: Data Structures, Algorithms, Design Patterns, Performance, … Missing: Linguistic Course, Domain-Driven Design Pratices 2. We design development workflows for technical aspects 3. No visibility for the domain in our code Lack of feedback from product people or designers No reward to engineers for their contributing impact
Can we (Re)Design our Development Workflow with the Business in Mind?
Queries Commands function query(…args: unknown[]): NonNullable<unknown>; function command(…args: unknown[]): void; Read Write Questions: Ask facts about the system Fire & Forget Abilities/Authorization/Guards/Conditions/Criteria: May/should cause side effects Control acces Command-Query-Separation (CQS) Functions to either be commands that perform an action or queries that respond data, but neither both!
Queries: Presentation Logic / Control Flow Two Times Business Logic. Two Times Anti-Patterns Helper {{#if (feature-flag ‘PROPLUS’)}} Special Feature here {{/if}} Components import Component from ‘@glimmmer/component’; import { service } from ‘@ember/service’; import type FeaturesService from ‘whereever/features-infra-sits’; class Search extends Component { @service declare features: FeaturesService; get isProPlus() { return this.features.has(‘PROPLUS’); } What’s the name of the feature? <template> {{#if this.isPropPlus}} hint: it is not “Pro Plus”, that’s only the feature flag currently Special Feature here {{/if}} </template> used for its condition Not unit testable :( }
Queries: Data Fetching Fetching data from your API Business logic part: Endpoint Parameters Payload structure
Commands: Actions Components import Component from ‘@glimmer/component’; import { action } from ‘@ember/object’; import { AnotherComponent } from ‘your-ui’; class Expose extends Component { @action onClick() { // whatever happens here Services import Service from ‘@ember/service’; class UserService extends Service { createUser(data) { // … } deleteUser(userId: number) { } // … } <template> <AnotherComponent @onClick={{this.onClick}}> Something sits here </AnotherComponent> </template> } }
Services Services is an overloaded Term Infrastructure Services Application Services Domain Services API client Session Domain Objects (CRUD) Messaging / Message Broker Features e.g. UsersService A/B Testing
We host Business logic in Components, Services, Routes, Controllers, Models merely to use Ember’s DI system. We created a strong coupling of business logic to Ember’s DI system 🤔
What is the correct Statement? (A) Make a Framework a Dependency of your Business? (B) Your Business drives Implementation within a Framework?
Rideshare Example On the Development of Reactive Systems with Ember.js Domain Modeling Made Functional Domain Modeling M… M… On the Development… Development… by Scott Wlaschin by Clemens Müller and Michael Klein
interface User { id: string; name: string; type: ‘rider’ | ‘driver’; } type RideState = | ‘requested’ | ‘declined’ | ‘awaiting_pickup’ | ‘driving’ | ‘arrived’ | ‘payed’ | ‘canceled’; interface Ride { id: string; from: string; to: string; riderId: string; driverId: string; state: RideState; }
interface User { id: string; name: string; type: ‘rider’ | ‘driver’; } // actions function request(ride: Ride, rider: User): void; function accept(ride: Ride, driver: User): void; function drive(ride: Ride, driver: User): void; function arrive(ride: Ride, driver: User): void; function pay(ride: Ride, rider: User): void; type RideState = | ‘requested’ | ‘declined’ | ‘awaiting_pickup’ | ‘driving’ | ‘arrived’ | ‘payed’ | ‘canceled’; function cancel(ride: Ride, user?: User): void; interface Ride { id: string; from: string; } // guards rsp. abilities function canRequest(ride: Ride, user: User): boolean; function canAccept(ride: Ride, user: User): boolean; function canDrive(ride: Ride, user: User): boolean; function canDecline(ride: Ride, user: User): boolean; function canArrive(ride: Ride, user: User): boolean; function mustPay(ride: Ride, rider: User): boolean; // questions function isDriver(user: User): boolean; to: string; riderId: string; function isRider(user: User): boolean; function isDriverFor(ride: Ride, driver: User): boolean; driverId: string; state: RideState; function calculateTravelDistance(ride: Ride): number;
Implementation Goal Ride Details Page Task Based UI Domain Code in plain TS Thin layer in Ember for DI integration import { canAccept, mustPay, accept, pay } from ‘your-domain’; import { Button } from ‘@hokulea/ember’; import type { TOC } from ‘@ember/component/template-only’; import type { Ride } from ‘ember-domain’; interface RideActionsSignature { Args: { ride: Ride; } } Given User is given as part of SessionService APIClient is our APIService const RideActions: TOC<RideActionsSignature> = <template> {{#if (canAccept @ride)}} <Button @push={{fn (accept) @ride}}>Accept</Button> {{/if}} {{#if (mustPay @ride)}} <Button @push={{fn (pay) @ride}}>Pay</Button> {{/if}} </template> export { RideActions };
2.1. Actions 1. Bi-Directional API, Statechart, Event-Driven Architecture, CQRS/ES 2. Uni-Directional API, Statechart, CRUD 3. Uni-Directional API, CRUD
Implementing Scenario 1 Fire & Forget import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient }: { apiClient: APIClient }): void { await apiClient.post(/ride/${ride.id}/accept
, { driverId: driver.id }); }
Implementation to focus on:
Additionally to the Domain
Endpoint
Infrastructure/technically relevant parameters
Parameters
Develop against interfaces
Payload Structure
Perfect to mock for testing
Scenario 1: Setup
Scenario 1: Action Resource Statechart BE MessageBroker Consumer call accept POST /ride/:id/accept publishEvent(‘accept’) notifyAboutEvent(‘accept’) forward the Event updates Ride Updates UI Resource Consumer Statechart accept BE MessageBroker
Implementing Scenario 2 Fire & Play BE in FE import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient }: { apiClient: APIClient }): void { await apiClient.post(/ride/${ride.id}/accept
, { driverId: driver.id }); }
Scenario 2: Setup
Secnarion 2: Action
Implementing Scenario 3 Fire & Play BE in FE import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient }: { apiClient: APIClient }): void { await apiClient.post(/ride/${ride.id}/accept
, { driverId: driver.id }); }
2.2. Abilities function canAccept(ride: Ride, user: User) { // when… return ( // ride is in state requested… ride.state === RideState.Requested && // AND user is a driver isDriver(user) ); } use single exit functions no guards with early exits, we are only interested when something can be done, not when it can’t be done readability: use positive statements (non negated statements) annotate with comments to explain tricky nonreadable code for non-tech people (when necessary)
2.3. Integration with Ember
Abilities function canAccept(ride: Ride, user: User) { return ride.state === ‘requested’ && isDriver(user); }
Actions import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient await apiClient.post(/ride/${ride.id}/accept
, { driverId: driver.id }); }
{{#if (canAccept @ride)}} … {{/if}} <Button @push={{fn (accept) @ride}}>Accept</Button>
Abilities: ability() from ember-ability import { canAccept as upstreamCanAccept } from ‘your-plain-ts-domain’; import { ability } from ‘ember-ability’; const canAccept = ability((owner) => (ride: Ride) => { const session = owner.lookup(‘service:session’); const { user } = session; ember-sweet-owner return upstreamCanAccept(ride, user); }); import { sweetenOwner } from ‘ember-sweet-owner’; export { canAccept }; const { services } = sweetenOwner(owner); const { session } = services; {{#if (canAccept @ride)}} … {{/if}}
Actions: action() from ember-command import { accept as upstreamAccept } from ‘your-plain-ts-domain’; import { action } from ‘ember-command’; const accept = action(({ services }) => (ride: Ride) => { const { session, api } = services; const { user } = session; upstreamAccept(ride, user, { apiClient: api }); }); export { canAccept }; <Button @push={{fn (accept) @ride}}>Accept</Button>
Domain Code is actually tiny many tiny functions easy unit testing Plain TS can be integrated into multiple systems: thin integration layer into frameworks statecharts but: is still hard to write code like that that’s a naive design needs visibility a way to reward engineers
Finish the Development Workflow Design can we have a “magic number” (similar to code-coverage), that signals: “good code quality that follows our architecture design” I haven’t found one… (yet?) Follow nature: Indicator Species Bridge between engineers and non-tech-people Use: typedoc
Configure typedoc Organize our domain aspects: /** * @group Domain Objects * @module Ride / Give meaning to our code: /* * @category Abilities * @source */ Plugin: typedoc-plugin-inline-sources Configure typedoc: “navigation”: { “includeCategories”: true, “includeGroups”: true, “includeFolders”: false }, “categorizeByGroup”: false
Benefits Make complexity visible Significant reduction in bugs Feature devlivery improved by factor 2-3x Increased developer velocity Business logic Lego
PART 2 Organizing Code and Scale it Up Strategic Design
Naive Approach Use Ember Addons Use Ember Engines Move things from app into addons/engines “False” Scalability
Example: A Zoo The technical goal is to keep animals and visitors separated Technical Let’s make a compound for animals and a compound for visitors Missing accomplished Domain Who put herbivores and carnivores in the same compound ? Short term attraction No long term, sustainable solution Frontend Architecture: How to Bu… Bu…
Domain Understanding subdomains Core Subdomain Unique/Core part of your product. Supporting Subdomain Ancillary parts that support your core. Generic Subdomain We’ll find these parts in many applications (e.g. user management). Subdomains help you distill your product into manageable pieces.
Time to Solve that Puzzle
github.com/gossi/unidancing
Colophon UniDancing.art Each domain directory has an index.gts which contains the public API Routes are exported as part of each domains public API // routes/exercises/index.gts export { IndexRoute as default } from ‘../../domain/core/exercises’; ember-polaris-routing : for defining routes (there is also ember-route-template ) ember-polaris-service : Infrastructure located in their respective domain (no root level services/ directory)
What’s Inside a Subdomain? Domain Objects Actions Abilities Questions Components Routes Services / Resources Public API as gateway to export what is accessible from the outside
Monolith Modular Monolith
Modular Monolith 1. Directory: domain/ 2. Monorepo: Private and public packages packages per subdomain 3. Polyrepo: One repository per subdomain with private and public packages Scaling Up Modular Monolith Directory Monorepo Monolith Polyrepo Microfrontend
Modular Monolith: Polyrepo One repo per subdomain Pro @unidancing/training Use the physical boundaries of a repo for core internal/public API public-api Everything public API is published to your registry ember-core (addon) ember (addon) main (engine) Contra You need the publish/update dance Use release-plan Use renovate / dependabot to automate updates Tip Legend: Internal Public
Modular Monolith: Monorepo One repo for all subdomains Pro domain/core/ No need to for publishing/updating choreography/ Faster development time training/ core ember (addon) Contra Needs to mimic the boundaries of a polyrepo exercises/ Linting is required! Extra tooling for linting against internal/public APIs Tip Legend: Internal Public
Microfrontend Subdomain independently deployable Ember engines would be the technological choice Currently not possible ember-engines Use them for isolated context Do NOT use them for route/chunk splitting (use embroider for that) Similar to “composable components”, Ember will have “composable apps” - and I think that is beatiful The technical solution for this is unclear as of now (apps and engines might merge)
Takeaways Focus on the domain Make your domain/complexity visible Reward your engineers for their contribution impact Your domain tells you how to scale up
Thank You :) Thomas Gossmann - gos.si - @unistyler