javascript

Angular Signal-Based Architecture: Building a Smarter Shopping Cart

Sonu Kapoor

Sonu Kapoor on

Angular Signal-Based Architecture: Building a Smarter Shopping Cart

In part one of this series, we explored how Angular Signals shift the reactive model away from the RxJS-centric approach we’ve relied on for years. We walked through the core API signal(), computed(), and effect() primitives, and demonstrated how they simplify state management by removing the need for subscriptions, teardown logic, and deeply nested observables. We also introduced a minimalist CartService that held cart items in a private signal and exposed a computed total price.

In this second part, we’re going to build on that foundation. We’ll extend the CartService with a few critical capabilities such as removing items, clearing the cart, and tracking the number of products. We’ll also explore how this service integrates with Angular components and templates, using signals directly without the need for RxJS or the async pipe. Along the way, we’ll highlight how this approach enhances testability, performance, and debuggability.

Our focus isn't to build the UI yet (that’s coming in the next article), but to mature the internal architecture so that it can support more advanced features like discounts, inventory checks, and persistence down the road. Our goal is to set up a fully reactive service layer that components can consume without worrying about subscriptions or manual change detection.

Extending the CartService

In part one, our CartService had the private _items = signal<CartItem[]>([]) and an addItem(item) method to push items into the cart. It also included a computed total that calculated the overall cost. That was a solid start, but minimal. Let’s now flesh it out with the typical operations you’d expect from a shopping cart.

We’ll start with a method to remove items based on their product ID. With RxJS, this would typically involve grabbing the current value from a BehaviorSubject, cloning the array, and pushing the updated result using .next(). It’s also common to see developers keep a separate local array, mutate it directly, and then emit it again (often unintentionally breaking reactivity).

Here’s how that looks with a common RxJS setup:

Listing 1: RxJS with Manual Local Array (Anti-Pattern)

ts
private cartItems: CartItem[] = []; private cart$ = new BehaviorSubject<CartItem[]>([]); removeItem(productId: string) { this.cartItems = this.cartItems.filter(item => item.id !== productId); this.cart$.next(this.cartItems); }

While this might look simple, it introduces several pitfalls. First, you're managing two sources of truth: the local cartItems array and the observable stream. This increases the risk of bugs, especially if cartItems is mutated in place or not correctly pushed to the subject. Second, this pattern is inherently brittle: it relies on developers remembering to call .next() every time, and any mistake can leave the stream out of sync with the actual data.

Now compare that with the signal-based version:

Listing 2: Removing an Item with signal()

ts
removeItem(productId: string) { this._items.update(items => items.filter(i => i.id !== productId)); }

This version eliminates the need for a separate array. The signal is your single source of truth. There’s no .next(), no .value, and no extra storage: just a single call to update(), and Angular automatically handles any reactive downstream updates. Derived values like total or totalCount (which we’ll add shortly) re-run only when needed. You don’t need to wire up extra logic to keep them in sync.

The update() function works by passing the current value of the signal into a user-defined callback and replacing the signal’s value with the result. This is ideal for incremental updates like filtering out an item or modifying quantities to base your new state on an existing one. While set() is available for overwriting the state entirely, update() is safer and more expressive in most cases. It encourages writing pure, testable state transitions, and avoids the accidental mutations or copy-paste bugs common in imperative code.

Clearing the cart is just as straightforward:

Listing 3: Clearing the Cart

ts
clearCart() { this._items.set([]); }

This simplicity is one of the strongest arguments in favor of using signals inside services. Instead of juggling internal arrays, manually pushing values, or setting up pipelines, you just update the signal directly, immutably, predictably, and safely.

Adding More Derived State

We already introduced a computed total in Part 1, which gave us the subtotal of all items in the cart. Let’s now add another computed signal: totalCount, which tracks the number of items in the cart. This is ideal for badge icons or small summaries across the app.

Listing 4: Tracking Total Item Count

ts
readonly totalCount = computed(() => this._items().length);

This makes the CartService richer, more informative, and easier to consume. The items, total, and totalCount values are declarative. They update automatically when dependencies change, without any need for orchestration logic.

The beauty of this setup is that the CartService becomes the single source of truth for cart state. It encapsulates all business logic and derived values. The service doesn't expose raw signals; it exposes finalized, readonly signals and methods. This means you can consume it safely from anywhere in your app without accidentally causing state corruption.

Injecting and Using the CartService in Components

Now let’s turn our attention to how this service is consumed. In traditional RxJS-based Angular apps, component authors often juggle multiple subscribe() calls, rely on the async pipe, or create local Observable streams that have to be torn down manually. All of that is eliminated with signals.

Let’s say you want to show a cart icon in the header with a total number of items. Here’s what that looks like with the signal-based CartService.

Listing 5: Consuming the CartService in a Component

ts
@Component({ selector: "app-cart-badge", template: `Items in cart: {{ cart.totalCount() }}`, changeDetection: ChangeDetectionStrategy.OnPush, }) export class CartBadgeComponent { readonly cart = inject(CartService); }

This is where the signal-based model truly shines. There’s no async pipe, no .subscribe() in ngOnInit(), and no teardown logic. The call to cart.totalCount() is just a function call, but behind the scenes, Angular knows this is a reactive read. If totalCount() changes, the component will re-render automatically, thanks to Angular's new fine-grained reactivity model.

There’s also no risk of accidental change detection issues here. Because signals are synchronous, the component reads the most up-to-date value every time. Combined with ChangeDetectionStrategy.OnPush, this leads to leaner renders and faster UIs.

Improving Testability with Signals

One of the biggest advantages of signals that's often underestimated is how effortlessly they fit into unit tests. Since signals are synchronous and have no teardown requirements, testing them feels just like testing regular JavaScript functions. There's no need for subscriptions, fakeAsync, or done() callbacks.

Let’s start with a basic test using our signal-based CartService to confirm that totals and counts are calculated correctly after adding items.

Listing 6: Unit Test for Signal-Based CartService

ts
it("should compute total and count after adding items", () => { const cart = new CartService(); cart.addItem({ id: "1", price: 10, qty: 2 }); cart.addItem({ id: "2", price: 5, qty: 1 }); expect(cart.total()).toBe(25); expect(cart.totalCount()).toBe(2); });

This test is clean, synchronous, and requires no special Angular testing utilities. It just works.

There’s no mocking, no subscriptions, no waiting for emissions. You instantiate the service, call its methods, and verify the computed results. This dramatically reduces the cognitive load of writing tests and encourages teams to test more frequently and thoroughly.

Even more advanced scenarios like testing effect() logic (which we’ll cover in part three of this series) can be handled cleanly with TestBed.tick() provided by Angular's testing utilities. But even without that, most logic in a signal-powered service remains synchronous, predictable, and side-effect free by default.

Now contrast that with how a similar test might look using RxJS and an asynchronous observable source. As soon as you introduce any delay, like simulating an HTTP call, you're already reaching for done() or fakeAsync() to manage asynchronous behavior.

Listing 7: RxJS Test Without Marbles

ts
it("should compute total with async observable", (done) => { const items$ = of([ { id: "1", price: 10, qty: 2 }, { id: "2", price: 5, qty: 1 }, ]).pipe(delay(10)); // Simulates async behavior const total$ = items$.pipe( map((items) => items.reduce((sum, i) => sum + i.price * i.qty, 0)) ); total$.subscribe((total) => { expect(total).toBe(25); done(); // Remove this line and your test will silently timeout }); });

Here, even for something as trivial as computing a total, we need to simulate async emissions, manage done() timing, and write verbose test logic. If the test fails, it may silently time out, giving you a delayed red herring instead of a clear assertion failure.

The Signal-based test above was entirely synchronous, deterministic, and didn’t require any test runner gymnastics. This clarity scales as your app logic grows.

Now imagine you need to test stream timing or multiple emissions over time, for instance, user input or retry logic. RxJS introduces marble diagrams, a powerful but heavyweight abstraction that requires a dedicated mental model and TestScheduler utilities.

Listing 8: RxJS Test Using Marbles

ts
it("should compute total using marbles", () => { testScheduler.run(({ cold, expectObservable }) => { const items$ = cold("a", { a: [ { id: "1", price: 10, qty: 2 }, { id: "2", price: 5, qty: 1 }, ], }); const total$ = items$.pipe( map((items) => items.reduce((sum, i) => sum + i.price * i.qty, 0)) ); expectObservable(total$).toBe("a", { a: 25 }); }); });

This test is technically elegant, but it requires understanding marble syntax ('a'), managing a scheduler, and often abstracting real-time behavior into symbolic emissions. For developers new to RxJS, this adds an entirely new DSL on top of an already complex testing stack.

By contrast, signals eliminate the need for any of this. State is pulled synchronously, derived values are automatically updated, and tests are as simple as calling methods and making assertions.

The simplicity here is not just about fewer lines of code, it’s about cognitive clarity. Signals reduce test complexity, making unit testing feel lightweight again. And that encourages developers to test their services more frequently and with more confidence.

Architectural Benefits of the Signal-Based Service

At this point, our cart service contains private mutable state (_items), public readonly selectors (total, totalCount), and exposed methods (addItem, removeItem, clearCart). This is a clean architecture. It enforces the principle of encapsulation, ensures unidirectional data flow, and keeps the mutation logic close to the state it affects.

Because signals are designed to be tightly scoped and synchronous, they naturally discourage over-engineering. You don’t need actions, selectors, or reducers just to update a list. You don’t need combineLatest to calculate a subtotal. And you don’t need a test harness to assert your logic works.

This is where signals outperform not only RxJS, but also many other state management solutions in Angular today. They reduce friction, improve clarity, and make architectural choices simpler, not harder.

Just as important, this architecture is ready to scale. Whether we’re adding coupon logic, syncing state to localStorage, or reacting to inventory availability, we now have a solid service that we can evolve cleanly without introducing new state bugs or performance bottlenecks.

Preparing for Real-World Features

What we’ve built so far is the core engine of a reactive cart, but there are plenty of real-world concerns we haven’t yet addressed.

In part three, we’ll introduce discount codes, inventory checks, and side-effect handling using Angular’s effect() function. We’ll also show how signals improve performance through precise reactivity, and how that ties into production observability and monitoring with AppSignal.

But before we go there, it’s worth pausing to appreciate what this second step gives us: a scalable, reactive, easy-to-test cart service that holds all business logic in one place and can be consumed declaratively from any component in an app.

In a traditional RxJS-powered app, this level of cleanliness usually requires careful discipline or a state management library like NgRx or Akita. With signals, it’s the natural default.

What’s Next: Smarter State and Better Debugging

In our final article of this series, we’ll move beyond local state and into the domain of side effects, reactivity chaining, and advanced computed logic. We’ll look at how to use effect() to sync our cart with browser storage or the backend, how to validate items against inventory, and how to apply coupon codes with reactive pricing logic.

The key takeaway from this second part is that signals allow you to think locally and architect globally. You can write state logic as simple functions and signals, plugging them into a larger structure that is inherently reactive, testable, and extensible.

Until next time, happy coding!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Sonu Kapoor

Sonu Kapoor

Guest author Sonu Kapoor is a seasoned Senior Software Engineer and internationally recognized speaker with over two decades of experience in building high-performance web applications. He is a Microsoft MVP (2005–2010, 2024) and a Google Developer Expert for Angular, as well as a trusted contributor to the Angular Framework, where he has helped shape the future of frontend development. Besides that, he has published two books.

All articles by Sonu Kapoor

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps