diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py
index 495ea6d..fdd2bd7 100644
--- a/plugins/module_utils/common.py
+++ b/plugins/module_utils/common.py
@@ -3,65 +3,174 @@
 
 from __future__ import annotations
 
-from typing import Any
-import yaml
-from pydantic import BaseModel, ConfigDict
+import copy
+from dataclasses import asdict, dataclass, field, replace
+from typing import Any, Callable
 
+import yaml
 from ansible.module_utils.basic import AnsibleModule
 
+PROJECTS_DIR = "/var/lib/ez_compose"
+BASE_SERVICE_ARGS = {
+    "project_name": {
+        "type": "str",
+        "required": True,
+    },
+    "name": {
+        "type": "str",
+        "required": True,
+    },
+    "image": {
+        "type": "str",
+    },
+    "state": {
+        "type": "str",
+        "default": "present",
+        "choices": ["present", "absent"],
+    },
+    "defaults": {
+        "type": "dict",
+    },
+}
 
-class Result(BaseModel):
-    model_config = ConfigDict(frozen=True)
 
+@dataclass(frozen=True)
+class Result:
     changed: bool = False
-    diff: dict[str, Any] = {}
+    diff: dict[str, Any] = field(default_factory=dict)
 
 
-class Settings(BaseModel):
-    model_config = ConfigDict(frozen=True)
-
+@dataclass(frozen=True)
+class Settings:
     projects_dir: str = "/usr/local/share/ez_compose/"
 
 
-class State(BaseModel):
-    model_config = ConfigDict(frozen=True)
-
-    module: AnsibleModule
+@dataclass(frozen=True)
+class State:
+    module: Any  # Replace Any with the actual type of AnsibleModule if available
     result: Result
-    settings: Settings
-    compose_filename: str
-    before: dict[str, Any] = {}
-    after: dict[str, Any] = {}
+    compose_filepath: str
+    before: dict[str, Any]
+    after: dict[str, Any] = field(default_factory=dict)
 
 
-def new_state(module: AnsibleModule) -> State:
-    settings = Settings(**module.params["settings"])
+def _recursive_update(default: dict[Any, Any], update: dict[Any, Any]) -> dict[Any, Any]:
+    for key in update:
+        if isinstance(update[key], dict) and isinstance(default.get(key), dict):
+            default[key] = _recursive_update(default[key], update[key])
+
+        elif isinstance(update[key], list) and isinstance(default.get(key), list):
+            default_set = set(default[key])
+            custom_set = set(update[key])
+            default[key] = list(default_set.union(custom_set))
+
+        else:
+            default[key] = update[key]
+
+    return default
+
+
+def get_state(module: AnsibleModule) -> State:
+    compose_filepath = f"{PROJECTS_DIR}/{module.params['project_name']}/docker-compose.yml"
+
+    try:
+        with open(compose_filepath, "r") as fp:
+            before = yaml.safe_load(fp)
+
+    except FileNotFoundError:
+        before: dict[str, Any] = {}
 
     return State(
         module=module,
         result=Result(),
-        settings=settings,
-        compose_filename=(
-            f"{settings.projects_dir}/"
-            + f"{module.params['project_name']}/"
-            + f"{module.params['name']}.yml"
-        ),
+        compose_filepath=compose_filepath,
+        before=before,
     )
 
 
-def get_compose(state: State) -> State:
-    file = state.compose_filename
+def apply_service_base(state: State) -> State:
+    params = state.module.params
 
-    with open(file, "r") as stream:
-        compose = yaml.safe_load(stream)
+    compose = copy.deepcopy(state.before)
+    services = compose.get("services", {})
 
-    return state.model_copy(update={"before": compose})
+    networks = compose.get("networks", {}).update(
+        {
+            f"{params['project_name']}_internal": None,
+        }
+    )
+
+    service = services.get(params["name"], {}).update(
+        {
+            "container_name": f"{params['project_name']}_{params['name']}",
+            "hostname": f"{params['project_name']}_{params['name']}",
+            "image": params["image"],
+            "restart": "unless-stopped",
+            "environment": {},
+            "labels": {},
+            "volumes": [],
+            "networks": {
+                f"{params['project_name']}_internal": None,
+            },
+        }
+    )
+
+    services.update(
+        {
+            "networks": networks,
+            params["name"]: service,
+        }
+    )
+
+    return replace(state, after=compose)
+
+
+def set_defaults(state: State) -> State:
+    params = state.module.params
+    compose = copy.deepcopy(state.before)
+    services = compose["services"]
+    service = services[params["name"]]
+
+    _recursive_update(service, params["defaults"])
+
+    services.update({params["name"]: service})
+
+    return replace(state, after=compose)
+
+
+def update_service(state: State, update: dict[str, Any]) -> State:
+    compose = copy.deepcopy(state.before)
+    name = state.module.params["name"]
+    _recursive_update(compose["services"][name], update)
+    return replace(state, after=compose)
 
 
 def write_compose(state: State) -> State:
-    file = state.compose_filename
+    file = state.compose_filepath
 
     with open(file, mode="w") as stream:
         yaml.dump(state.after, stream)
 
     return state
+
+
+def run_service(
+    extra_args: dict[str, Any] = {},
+    helper: Callable[[State], State] = lambda x: x,
+) -> None:
+    module = AnsibleModule(
+        argument_spec=BASE_SERVICE_ARGS.update(extra_args),
+        supports_check_mode=True,
+    )
+
+    state = get_state(module)
+
+    for f in [apply_service_base, set_defaults, helper]:
+        state = f(state)
+
+    if module.check_mode:
+        module.exit_json(**asdict(state.result))  # type: ignore[reportUnkownMemberType]
+
+    write_compose(state)
+
+    module.exit_json(**asdict(state.result))  # type: ignore[reportUnkownMemberType]
diff --git a/plugins/modules/redis.py b/plugins/modules/redis.py
index fe05bc1..a6da3bf 100644
--- a/plugins/modules/redis.py
+++ b/plugins/modules/redis.py
@@ -1,76 +1,27 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
 # Copyright: (c) 2024, Luca Bilke <luca@bil.ke>
 # MIT License (see LICENSE)
 
-from __future__ import annotations
+# TODO: write ansible sections
 
-import copy
+DOCUMENTATION = r"""
+"""
 
-from ansible.module_utils.basic import AnsibleModule
-from ansible_collections.snailed.ez_compose.plugins.module_utils.common import (
-    State,
-    get_compose,
-    new_state,
-    write_compose,
-)
+EXAMPLES = r"""
+"""
 
+RETURN = r"""
+"""
 
-def generate(state: State) -> State:
-    params = state.module.params
-    compose = copy.deepcopy(state.before)
+from __future__ import annotations  # pyright: ignore[reportGeneralTypeIssues]
 
-    services = compose.get("services", {})
-
-    container = services.get(params["name"], {})
-    container.update(
-        {
-            "image": params["image"],
-        }
-    )
-
-    services.update({params["name"]: container})
-
-    return state.model_copy(update={"after": compose})
+from ansible_collections.snailed.ez_compose.plugins.module_utils.common import run_service
 
 
 def main():
-    module = AnsibleModule(
-        argument_spec={
-            "state": {
-                "type": "str",
-                "default": "present",
-                "choices": ["present", "absent"],
-            },
-            "name": {
-                "type": "str",
-                "required": True,
-            },
-            "project_name": {
-                "type": "str",
-                "required": True,
-            },
-            "image": {
-                "type": "str",
-            },
-            "settings": {
-                "type": "dict",
-                "required": True,
-            },
-        },
-        supports_check_mode=True,
-    )
-
-    state = new_state(module)
-
-    get_compose(state)
-
-    generate(state)
-
-    if module.check_mode:
-        module.exit_json(**state.result)  # type: ignore[reportUnkownMemberType]
-
-    write_compose(state)
-
-    module.exit_json(**state.result)  # type: ignore[reportUnkownMemberType]
+    run_service()
 
 
 if __name__ == "__main__":
diff --git a/plugins/pyproject.toml b/plugins/pyproject.toml
index 0b3e8e7..659b4b4 100644
--- a/plugins/pyproject.toml
+++ b/plugins/pyproject.toml
@@ -1,5 +1,5 @@
 [tool.black]
-line-length=100
+line-length = 100
 
 [tool.basedpyright]
 typeCheckingMode = "strict"
@@ -9,3 +9,8 @@ reportMissingTypeStubs = false
 # handled by ruff
 reportUnusedVariable = false
 reportUnusedImport = false
+
+
+[tool.ruff.lint]
+# Irrelevant for ansible
+ignore = ["E402", "F404"]