Skip to content

Signals don't make bad architecture better

Signals are good.

Really.

Fine-grained reactivity, clearer dependency tracking, less indirect subscription mechanics, better local derivability of state — those are real improvements.

But Signals are a reactivity primitive.

Not an architecture.

And that’s exactly where the misunderstanding starts.

A new reactivity model doesn’t fix misplaced responsibilities. It doesn’t prevent DTO leakage. It doesn’t draw layer boundaries. It doesn’t decide who owns state. It doesn’t automatically keep components small.

Anyone who had bad structure before can use Signals to build bad code that reacts very quickly.

Bad vs. good

New framework features often create a brief phase of collective hope.

Finally, everything gets simpler.

Finally, less boilerplate.

Finally, no more subscription hell.

Finally, no RxJS for every tiny piece of UI state.

And yes: a lot of that is true.

Signals can significantly simplify code. They can make local reactivity easier to understand. They can bring derivations closer to the state. They can keep you from staging half an Observable production for a trivial piece of UI state.

But they don’t answer architecture questions.

They don’t answer:

  • Where does business state live?
  • Who’s allowed to change it?
  • Where does the UI layer end?
  • Where do DTOs get translated?
  • Where do use cases get orchestrated?
  • How do multiple parts of the application react to one business event?
  • Which layer knows about infrastructure?
  • Which layer knows about presentation?

Signals give you a very good wire.

They don’t tell you where to run it.

When an API structure gets written straight into a signal, it’s still in the wrong layer.

type TaskDto = {
task_id: string;
created_at: string;
assigned_to_user_id: string | null;
};
@Component(...)
export class TaskCardComponent {
readonly task = signal<TaskDto | null>(null);
}

That’s not better just because task is now a signal.

The component still knows API field names. The template still knows transport details. created_at is still a string. assigned_to_user_id: null still has to get interpreted in business terms somewhere.

Signals don’t turn that into a clean boundary.

They just make the wrong boundary reactive.

It’s still better to do this:

type TaskViewModel = {
id: string;
createdAtLabel: string;
assigneeLabel: string;
};
function toTaskViewModel(dto: TaskDto): TaskViewModel {
return {
id: dto.task_id,
createdAtLabel: formatDate(dto.created_at),
assigneeLabel: dto.assigned_to_user_id ?? 'Unassigned',
};
}

The UI should consume a ViewModel.

Not the API’s transport model.

A signal inside a component isn’t automatically bad architecture.

Local UI state is completely fine there.

@Component(...)
export class OrderDialogComponent {
readonly open = signal(false);
show() {
this.open.set(true);
}
close() {
this.open.set(false);
}
}

That’s UI behavior.

It gets problematic when the component owns business workflows.

@Component(...)
export class OrderComponent {
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor(
private readonly http: HttpClient,
private readonly router: Router,
private readonly toast: ToastService,
) {}
submitOrder(data: OrderData) {
if (data.items.length === 0) {
this.error.set('An order with no line items isn\'t allowed');
return;
}
const payload = this.mapToPayload(data);
this.loading.set(true);
this.http.post('/api/orders', payload).subscribe({
next: () => {
this.toast.success('Order saved');
this.router.navigate(['/confirmation']);
},
error: () => {
this.error.set('Save failed');
},
complete: () => {
this.loading.set(false);
},
});
}
}

The problem isn’t signal().

The problem is that the component suddenly owns everything:

  • the business rule
  • mapping
  • HTTP
  • loading state
  • error state
  • toast
  • navigation
  • flow control

This was already bad with Observables before.

With Signals, it’s still bad.

Just written with a more modern API.

Reactive bad architecture

Signals can show up anywhere:

In components. In services. In stores. In facades. In mappers. In random utility classes where they’d be better off not existing.

That makes them flexible.

And that’s exactly why they’re dangerous.

When a team doesn’t have a clear ownership model for state, questions like these come up quickly:

  • Which signal is the source of truth?
  • Who’s allowed to call .set()?
  • Is the signal local, or business-relevant?
  • Is it UI state or application state?
  • Why does the same state get mirrored in three places?
  • Why does a service have a signal, a component have a signal, and the store too?

At that point you don’t have architecture.

You have distributed reactivity.

@Injectable()
export class OrderService {
readonly selectedOrderId = signal<string | null>(null);
}
@Component(...)
export class OrderListComponent {
readonly selectedOrderId = signal<string | null>(null);
}
@Injectable()
export class OrderFacade {
readonly selectedOrderId = signal<string | null>(null);
}

That’s not automatically wrong.

But without a clear rule, it’s suspicious.

Because now the same business idea exists in multiple places.

And eventually someone asks:

Why is the detail view on order 42, but the list has order 17 marked?

The answer usually isn’t: “Signals are bad.”

The answer is:

Nobody decided who owns this state.

Signals without ownership are global variables with extra steps

Section titled “Signals without ownership are global variables with extra steps”

A signal is seductively simple.

export const currentUser = signal<User | null>(null);

Done.

Import it everywhere. Read it everywhere. Write it everywhere.

What could possibly go wrong?

A lot.

Because that makes currentUser reactive, sure, but not architecturally controlled.

Who’s allowed to set the user? When does it get reset? What happens on logout? What happens on a token refresh? What happens on a tenant switch? Which tests need to prepare for this? Which components depend on it indirectly?

A global signal without ownership isn’t a state management solution.

It’s global state.

With a prettier API.

Signals are strong when they get used within clear boundaries.

For example:

Component
consumes ViewModel
sends intent
Facade / Store
owns state
coordinates UI-adjacent flows
exposes derived signals
Use Case / Application Service
executes business workflows
Infrastructure
talks to the API, storage, external systems

Then Signals fit very well.

@Injectable()
export class OrderFacade {
private readonly store = inject(OrderStore);
readonly vm = this.store.vm;
readonly isLoading = this.store.isLoading;
loadOrders() {
this.store.loadOrders();
}
submitOrder(data: OrderFormValue) {
this.store.submitOrder(data);
}
}

The component stays boring:

@Component(...)
export class OrderComponent {
private readonly facade = inject(OrderFacade);
readonly vm = this.facade.vm;
submit(data: OrderFormValue) {
this.facade.submitOrder(data);
}
}

The component doesn’t know what happens afterward.

It doesn’t know whether an HTTP call happens. It doesn’t know whether a toast appears. It doesn’t know whether navigation happens. It doesn’t know whether a list gets reloaded.

It sends an intent.

That’s its job.

Signals with ownership

A signal store can be a very good application facade.

But a store can have bad boundaries too.

When everything lands in one store, you just get a new data heap:

type AppState = {
user: User | null;
orders: Order[];
selectedOrderId: string | null;
dialogOpen: boolean;
formDraft: OrderFormValue | null;
permissions: string[];
theme: string;
toastMessage: string | null;
tempFlag: boolean;
};

That’s not better just because it’s a signal store.

A store needs boundaries.

Better:

AuthStore
user
permissions
OrdersStore
orders
selectedOrderId
loading
error
commands
OrderDialogStore
local dialog state
draft state

Not every piece of state belongs in the same place.

Signals make state easy.

Precisely because of that, you have to decide which state belongs where.

computed() is great for derivations.

readonly fullName = computed(() => {
const user = this.user();
if (!user) {
return 'Unknown';
}
return `${user.firstName} ${user.lastName}`;
});

That’s harmless.

But there’s a limit here too.

When complex business rules suddenly land inside computed(), caution is warranted.

readonly discount = computed(() => {
const order = this.order();
if (!order) {
return 0;
}
if (order.customer.type === 'premium' && order.items.length > 10) {
return 0.15;
}
if (order.customer.country === 'DE' && order.total > 1000) {
return 0.1;
}
return 0;
});

Maybe that’s fine.

But maybe it’s a business rule that shouldn’t live in a UI-adjacent derivation.

The question isn’t:

Am I allowed to use computed?

Of course.

The question is:

Is this a presentation derivation, or a business decision?

Presentation derivations fit well in a store, a facade, or ViewModel mapping.

Business decisions belong in a use case, a domain function, or an application layer.

Effects aren’t a trash can for side effects

Section titled “Effects aren’t a trash can for side effects”

effect() isn’t an architecture concept either.

An effect can make sense for reacting to state changes.

But when effects get scattered indiscriminately across components and services, you end up with invisible coupling again.

effect(() => {
if (this.saved()) {
this.toast.success('Saved');
this.router.navigate(['/orders']);
this.reload();
}
});

That can work.

But you should know exactly why this effect sits right here.

Because effects are reactions.

Reactions need ownership.

Who owns the save flow? Who decides on navigation? Who decides on toasts? Who reloads which resource? How do you test this? What happens if a second spot also reacts to saved()?

Without a concept, effect() quickly turns into:

Something else just happens somewhere, I guess.

That’s not architecture.

That’s reactive fog.

A good Signals architecture isn’t spectacular.

It looks roughly like this:

DTO in
Mapper translates
Store holds state
computed produces ViewModel
Component renders
Intent goes out
Use case runs
Result updates state

That’s not new.

That’s the point.

Signals don’t change the basic principles of good frontend architecture.

They just make some implementations more pleasant.

The difference isn’t whether you use signal(), Observable, Subject, Redux, or a signal store.

The difference is whether responsibilities are clear.

Signals are well suited for:

  • local UI state
  • derived state
  • ViewModels
  • feature stores
  • facades
  • readable reactive data flows
  • fine-grained updates

But Signals don’t automatically solve:

  • layer separation
  • DTO mapping
  • use-case orchestration
  • ownership of state
  • clean event flows
  • business modeling
  • testability
  • component responsibility

The technical question is:

Can I build this with Signals?

The architectural question is:

Should this state or this decision live here?

The second question matters more.

Signals are a good tool.

But they’re not a substitute for architecture.

They improve the implementation of good structures. They don’t rescue bad ones.

Anyone shoving DTOs straight into components still has DTO leakage with Signals.

Anyone writing use cases inside components still has business logic in the UI layer with Signals.

Anyone scattering state without ownership still has uncontrolled state with Signals.

Anyone hiding side effects indiscriminately in effects still has hard-to-follow flows with Signals.

Signals make reactive programming in Angular more pleasant.

But they don’t answer the old, uncomfortable question:

Who’s responsible for what?

The short rule:

Signals make good code more readable. They just make bad architecture visible faster.

Or, a bit more unkindly:

Anyone who was building spaghetti before doesn’t get lasagna with Signals. Just finer noodles.