Believe it. Ansible is a bit of a mess when it comes to composability, but it’s slowly becoming better with each release (mostly.)
You can always host logic within subdirectories. For example, I keep all of my tasks broken down into “task include” files in my “tasks/” subdirectory. Each task include file is composed such that it expects every necessary variable to be passed into it.
There’s still a bit of a problem with include files “returning” values up the chain. Thus, it’s hard to write playbook submodules that, for instance, inspect the state of a system and then return it to the master for further use in other submodules. Even “set_fact”, when executed within a task include file, doesn’t reliably allow that fact to be seen by ancestors or peers.
Part of the problem is that Ansible mostly seems to be written with a “declarative” philosophy, but many of the modules have “imperative” logic baked into their arguments. It would be nice if Ansible explicitly supported both declarative and imperative logic, because hey, that’s really useful for a huge number of circumstances.
Another problem is that in its desire to be declarative, Ansible seems to lack the most basic of functionality in some of their modules. For example, can you believe that the “service” module only lets you enable or disable services, but not query the state of a service? That’s right, if you wanted to write a task or role that, for example, selectively targets iptables or firewalld based upon which service was active, you’d have to jump through real hoops to do that. In another example, the “firewalld” module uses the “state” argument to determine whether a rule should be allowed (state=“enabled”) or denied (state=“disabled”). However, that flies in the face of firewall configuration in general, because you’d want to be able to “add” and “remove” either type of firewall rule (i.e. “accept” or “deny”). So here, because Ansible wants to appear “declarative” instead of “imperative”, we now have a module that makes little to no sense.
So for composability purposes, it’s really hard to write generic-type tasks or roles that work regardless of various differences between target system. In this sense, Ansible really IS declarative, because it forces you to define, ahead of time, exactly what the system is supposed to look like, NOT how it’s supposed to respond depending upon variable configuration. If you want something more elaborate than that, you have to jump into Python and write your own modules.