Update [to example scenario]: [The] users cannot enter any promo codes unless the value of the basket exceeds $50.
Your example case certainly qualifies as business logic (latest since the clarified example), that itself however does not necessarily imply your UIs conditional code being relevant to the business process.
Security
Your code (items.Count == 0
) itself is being transferred to the client and thereafter executed on the client's machine, i.e. in an entirely uncontrolled environment. Regardless of how well-written1, unit-, integration- and e2e-tested it may be, it's in the nature of the runtime env that it can be tampered with at will.
This is why it cannot make important decisions. Ever.
It's not the Javascript, it's not the engineers, it's the execution environment.
The decisive evaluation of the condition for promotions being fulfilled must happen server-side during the checkout step of the sales process.
Decoupling
The attempt to literally absolutely "decouple" a user interface from a server-side process, both of which are meant to model the same real-world process (a sale), has - in my limited experience - always consumed vast amounts of resources that could have been allocated much more sensibly. I was once witness to a sad, slow, painful death in the vastness of the desert of layers of abstraction, useless indirection, and false generality. It wasn't fun to be a part of at all.
Deduplication
Security aside, shipping your code to production as proposed will lead to a regression in overall maintainability of the software.
In your example, the predicate that must be true for a promo code to be valid is net total sale exceeding the threshold of $50. Easy enough a condition to check.
- What if management decides to change the threshold value?
- What if the predicate is being repeatedly extended over time by further conditions?
(the customer must have been registered for period n
, the annual total spend of the customer must exceed m
, e.g.)
- What if the threshold is specified to become a derived value of sorts?
(say, $50 on a rainy day, $10 when it's sunny out and $200 the week before the holiday's)
Or would you have this rule in your DTO with a property called CanUserInputPromoCode
? Even then, the DTO isn't part of the domain logic, is it?
No.
That wouldn't be much better.
(Though better.)
The predicate condition(s) in the UI code should be statically generated during build-time from the backend source code directly or from a shared, version-controlled configuration file.
Iff requirements ever become so dynamic that the predicate must be variable at runtime, then the client application should fetch the latest condition(s) as needed from a dedicated server-side endpoint (or from a dedicated server-side query resolver, for the cool kids in the GraphQL camp).
A fictitious, but realistic scenario
In terms of the layering of the codebase and day-to-day interdependence between the work of UI engineers, backend engineers and your DevOps team, go ahead, decouple away.
Generally, I am rather flexible on the topic of language / stack choice, too.
However, some language(-family) that supports Generics (Java, C#, Typescript), ideally static, compile-time polymorphism (Rust, C++) for the server-side is invaluable.
Again, deduplicate, automate, abstract.
I want a significant part of my runtime code to be generated. I don't want to see macros or a templating language (moustache, handlebars, ejs, etc.) in my codebase either though. Generated code should be compiled, type-checked, tested - it deserves all the usual engineering goodness the rest of your codebase (hopefully) enjoys.
Say some team member implements a new kind of HTTP response never used in the software so far. They add HttpStatusImATeaPot = 418
to enum class HttpStatusCode {}
in the backend's HTTP utility library and use it in an endpoint.
During build, the same key/value should be automatically added to a generated Typescript enum of the same name. At this point, the UI's development build should issue a warning on an exhaustive check that an enum
value is missing from a switch statement in the client http response handler. CI builds should fail with an error. Continuous Deployment should exclude the merge until the UI has caught up.
In case of compilation misconfig (e.g.) and a build having succeeded that shouldn't have, an end-2-end test before deployment should verify the HTTP responses of the runtime code.
This isn't a one-off requirement either. I want this to be available to the UI team for every single <InsertFancyBusinessProcessName>(Rejection|Success)Reason
. For every subclassed error or exception that is intended for the client.
--> How are you going to "decouple" that?
--> The entire premise is faulty, I believe.
N.B. I am not advocating for any significant amount of runtime symbols /symbol names to be shared. Literal values and types though? yes please!
Conclusion
Integration and end-to-end tests, for instance, are not (superfluous) tight coupling. On the contrary, those are a pre-requisite for the long-term success of a project.
Having a common foundation (shared static values, depending on languages involved shared library code) between layers is not tight coupling at all.
That's simply sane architecture.
If UI and Backend are so professionally "decoupled" indeed, that UI engineers are manually duplicating semantically meaningful literals, which already exists on the other end of an API, then something's gone in an entirely wrong direction.
If they are "so totally decoupled" from the API that they must maintain their own adapter code for it, whose benefiting, exactly?
1 Sidenote: Use strict equality checking in ES.
n
promo codes, and now you have 2 pieces of business logic in the UI. That logic should live in a business object, from the start.