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 itemsOne 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