Under the Hood: The Menu System in Code

A follow-up to A Menu System Built for Change, Not Just for Now

In the previous post I described the design behind the menu system: declarative structure, server-side filtering, aggregated permissions, and why keeping the frontend out of those decisions matters. A few readers asked to see the actual code. Fair enough. Let’s walk through the most interesting parts.


The YAML File That Runs the Menu

The entire menu structure lives in a single file, menu.yml, compiled as an embedded resource in the API assembly. Here’s a representative excerpt showing Accounts Payable, a top-level group with two second-level groups, each with leaf items carrying their permission tokens:

- Name: accounts-payable
  Label: Accounts Payable
  Path: /session/accounts-payable
  Icon: paid
  MenuItems:
  - Name: accounts-payable-invoices
    Label: Invoices
    Path: /session/accounts-payable/invoices
    Icon: list
    MenuItems:
    - Name: accounts-payable-invoice-list
      Label: List
      Path: /session/accounts-payable/invoices/list
      Icon: list
      Permission: AccountsPayable.Invoice.List
    - Name: accounts-payable-invoice-record
      Label: Record Received Invoice
      Path: /session/accounts-payable/invoices/receive-invoice
      Icon: move_to_inbox
      Permission: AccountsPayable.Invoice.Receive
    - Name: accounts-payable-approve-payments
      Label: Approve Payments
      Path: /session/accounts-payable/invoices/approve-payments
      Icon: price_check
      Permission: AccountsPayable.Invoice.ApprovePayment
  - Name: accounts-payable-payments
    Label: Payments
    Path: /session/accounts-payable/payments
    Icon: payments
    MenuItems:
    - Name: accounts-payable-payments-list
      Label: List
      Path: /session/accounts-payable/payments/list
      Icon: list
      Permission: AccountsPayable.Payment.List
    - Name: accounts-payable-payments-issue
      Label: Outstanding
      Path: /session/accounts-payable/payments/issue
      Icon: paid
      Permission: AccountsPayable.Payment.Issue

Each field has a clear job. Name is a unique identifier used as a data-cy attribute by the frontend’s Cypress tests. Label is what the user reads. Path is the Angular route. Icon is a Material Icons name rendered by <mat-icon>. Permission is the token the server checks. It’s optional, which is how container items work.

Notice that accounts-payable itself carries no Permission. That’s deliberate. Having a permission on the parent would mean “permission to Accounts Payable grants access to everything inside it.” We don’t want that. It would undermine the per-feature permissions of every future item added under that group. The parent stays visible because its children are reachable, not because someone was granted a blanket key.

One more thing worth mentioning: YAML is a practical format for this purpose. It’s compact, readable, and, as AI tooling has become a standard part of the workflow, straightforward for an AI assistant to navigate and edit. No curly braces, no noise. Just the structure and the data.

How the System Flows

Before going into the code details, here’s the full picture from startup to rendered sidebar:

sequenceDiagram
    participant Browser
    participant API
    participant MenuBuilder
    participant YAML as menu.yml (embedded)
    participant Store as Akita Store
    participant Sidebar as SidemenuComponent

    Browser->>API: GET /application-references (on login)
    API->>MenuBuilder: BuildFor(userId)
    MenuBuilder->>YAML: ReadManifestData("menu.yml")
    YAML-->>MenuBuilder: raw YAML string
    MenuBuilder->>MenuBuilder: Deserialize → MenuItem tree
    MenuBuilder->>MenuBuilder: AggregateAllPermissionsInTree()
    MenuBuilder->>MenuBuilder: FilteredTo(user.Permissions)
    MenuBuilder-->>API: filtered Menu
    API-->>Browser: { menu: { menuItems: [...] }, ...other refs }
    Browser->>Store: ApplicationReferencesStore.update(menu)
    Store-->>Sidebar: menu$ Observable emits
    Sidebar->>Sidebar: findAndSelectActiveMenuGroup(url)
    Sidebar->>Sidebar: render groups + active inline items

One request at startup. No dedicated menu endpoint. No separate round-trip. The menu arrives alongside other initialization data and is stored for the session.


The Backend: Building and Filtering

MenuBuilder is the backend entry point. Its single public method loads the YAML from the assembly’s embedded resources, deserializes it into objects, and filters the result down to what the authenticated user can see:

public Menu BuildFor(UserId userId)
{
    var user = _findUser.For(userId.Value);

    var deserializer = new DeserializerBuilder().Build();
    var menuYml = ReadManifestData<MenuBuilder>("menu.yml");
    var menuItems = deserializer.Deserialize<IEnumerable<MenuItem>>(menuYml);

    var menu = new Menu(menuItems);
    return menu.FilteredTo(user.Permissions);
}

Reading the YAML from the DLL’s manifest rather than from disk is also what makes the extensibility story clean. Right now it reads from an embedded resource. But that ReadManifestData call could be swapped for a read from Azure File Storage, a database query, or even a merge of a base YAML with per-user overrides, with no changes to the filtering logic or the frontend. The architecture is ready for that; the feature just hasn’t been wired in yet.

Menu wraps the item list and validates uniqueness of all Name values on construction, an early safety check that prevents the frontend’s data-cy test attributes from colliding.

Permissions That Roll Up

The filtering relies on AggregatedPermissions, a computed, cached property on each MenuItem:

string[] AggregateAllPermissionsInTree()
{
    var allPerms = MenuItems.Where(x => x.MenuItems != null)
        .SelectMany(x => x.AggregatedPermissions)
        .Concat(MenuItems.Where(_ => ItemRequiresA(_.Permission))
        .Select(_ => _.Permission))
        .ToList();

    if (ItemRequiresA(Permission))
        allPerms.Add(Permission);

    return allPerms.Distinct().ToArray();
}

Each node collects the union of all Permission values in its entire subtree: its own (if any), its children’s, and their children’s. This is what the Purchasing example from the previous post illustrates:

graph TD
    P["Purchasing<br/><i>no Permission</i><br/>AggregatedPermissions:<br/>VendorList.View, OrderList.View, Load.List, Load.Create"]
    V["Vendors<br/>Permission: Purchasing.VendorList.View"]
    O["Purchase Orders<br/>Permission: Purchasing.OrderList.View"]
    L["Loads<br/><i>no Permission</i>"]
    LL["List<br/>Permission: Purchasing.Load.List"]
    LC["Create Load<br/>Permission: Purchasing.Load.Create"]

    P --> V
    P --> O
    P --> L
    L --> LL
    L --> LC

When FilteredTo runs, it removes any item whose AggregatedPermissions set has no intersection with the user’s permissions. A user who can only access Purchasing.Load.List will still see the Purchasing group and the Loads subgroup, because both are ancestors of the one leaf they can reach.

flowchart TD
    A[Start: walk each top-level item] --> B{AggregatedPermissions ∩ userPermissions = ∅?}
    B -- Yes --> C[Remove item]
    B -- No --> D[Keep item, recurse into children]
    D --> E{Child: AggregatedPermissions ∩ userPermissions = ∅?}
    E -- Yes --> F[Remove child]
    E -- No --> G[Keep child]
    G --> H{Has own children?}
    H -- Yes --> E
    H -- No --> I[Leaf item kept]

Delivery: Bundled at Startup

The menu doesn’t have a dedicated endpoint. It’s included in the GetApplicationReferences handler that runs once at login:

void AddMenuTo(Dictionary<string, object> result)
{
    var menus = _menuBuilder.BuildFor(new UserId(_getCurrentUserInfo.GetUserId()));
    result.Add("menu", new { menuItems = menus.MenuItems });
}

The frontend receives { "menu": { "menuItems": [...] } } alongside other reference data, all in one request, stored in an Akita store for the session duration.


The Frontend: Just Render What You Receive

The frontend’s MenuItem interface is a direct mirror of the server’s shape:

export interface MenuItem {
  name: string;
  label: string;
  path: string;
  icon: string;
  permission?: string;
  menuItems: MenuItem[];
  aggregatedPermissions: string[];
}

export type Menu = MenuItem[];

aggregatedPermissions is computed on the server and carried down. The frontend never walks the tree to determine group visibility. It checks a flat array the server pre-calculated.

SidemenuComponent subscribes to the menu via a BehaviorSubject-backed service and synchronizes the active group with Angular’s router:

menu$: Observable<MenuItem[]> = this.menuService.menu$.pipe(
  tap(() => {
    this.initializeMenuState();
    this.synchronizeMenuStateWithNavigation();
  })
);

The active group renders its items inline beneath its header. Every other group shows a floating CDK Overlay popover on hover.

Two Rendering Modes, One Data Source

graph LR
    Menu["menu$ (Akita store)"]
    Menu --> Active["Active Group\nfb-sidemenu-item\n(inline, expanded)"]
    Menu --> Inactive["Inactive Groups\nfb-nested-menu-items\n(CDK Overlay popover)"]
    Active --> Leaf1["Leaf items\n*hasPermission"]
    Inactive --> Leaf2["Leaf items\n*hasPermission"]
    Active --> Sub["Nested sub-items\nfb-nested-menu-items\n(recursive)"]

Permission Directives: The Client-Side Guard

The backend already filtered the menu before the frontend received it. The frontend still applies structural Angular directives as a secondary, client-side guard:

Directive Where used Logic
*hasOneOfPermissions Group containers, popover items Visible if user has at least one of the aggregated permissions
*hasPermission Leaf item links Visible if user has the exact permission

*hasPermission also appears beyond the menu. Any button or UI element inside a feature that requires an explicit permission uses the same directive. The backend validates it too, but the frontend hides the affordance if the user doesn’t have it.


When Clicking a Group Goes Somewhere Useful

Clicking an inactive group header doesn’t just expand it. It navigates to the first item in that group the user has permission to reach, searching recursively:

gotoFirstAvailable(group) {

  const firstPermittedMenuItem = this.findFirstPermittedMenuItem(group.menuItems);

  if (firstPermittedMenuItem) {
    this.navigation.navigateTo(firstPermittedMenuItem.path, this.route);
  }

  this.hideOverlay();
}

No assumptions about which item that is. The search walks the tree depth-first and stops at the first leaf where permissionChecker.hasPermission(menuItem.permission) returns true.


Finding Things Without Knowing Where They Are

One feature built on top of this menu structure is the application-wide search bar. Users in a large system often know what they’re looking for but not where it lives. Type “payment” and the search scans through the same menu hierarchy (filtered to the current user’s permissions) and returns every matching item with its full breadcrumb path shown.

This uses the same aggregatedPermissions data. The menu is the source of truth for both navigation and discovery. And since the menu is already filtered to the user, the search results never surface items the user can’t actually reach.

graph TD
    Store["ApplicationReferencesStore\nAkita"]
    Store --> MenuService["MenuService\nBehaviorSubject<MenuItem[]>"]
    Store --> AppSearcher["AppSearcher\nflattens menu tree\ninto search results"]
    MenuService --> Sidebar["SidemenuComponent"]
    AppSearcher --> SearchBar["AppSearchBar\ntype to navigate"]

The search bar doesn’t have its own data source. It transforms the same MenuItem[] the sidebar uses, flattening the hierarchy into a searchable list. Permission filtering happened once, upstream, on the server. Everything downstream just uses what it received.


Testing

The backend tests follow a Given-When-Then pattern:

[Fact]
public void LIMITED_access_to_the_menu()
{
    given_a_user_has_LIMITED_permissions();
    given_a_menu_builder();
    when_menu_is_built();
    then_menu_returned_only_contains_the_limited_items();
}

A user with only Operations.User.Manage should receive exactly two items: the operations group and its users child. That’s it. The test is a direct specification of that business rule.

There are also tests for aggregated permissions, name uniqueness enforcement, structural fidelity after deserialization, and the full filtering algorithm, including the case where a user holds only a grandchild-level permission and should still see all ancestor groups. That last scenario didn’t have a test when I started. An AI pairing session caught the gap, wrote the test, watched it fail, and fixed the production code. The same session found a dead query class that was no longer referenced anywhere. Confirmed unused, removed, all tests still green.

The frontend test suite covers component behaviors (group rendering, active group detection, overlay grace period, tooltip visibility, nested item auto-expansion) with Jest unit tests and Cypress E2E tests for live navigation scenarios.


The Whole Picture

What I find most satisfying about this system is how little of it is clever. The YAML is plain text. The filtering is a recursive walk. The aggregation is a union of sets. The frontend just renders a list it received.

The complexity that users experience — the right items for the right person, the group that lights up as you navigate, the popover that doesn’t flicker when you cross it, the search that knows exactly what you can reach — comes from composing those simple pieces correctly, testing them well, and keeping each piece focused on one job.

That’s the whole picture.

Leave a Reply

Trending

Discover more from Claudio Lassala's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading