---
title: "Working with External Services"
slug: services
section: guides
order: 22
level: intermediate
description: "Calling REST APIs, Redis, and AWS from a LiveView without blocking the render."
---

# Working with External Services

How to integrate AWS, REST APIs, Redis, and other external services with djust LiveViews.

---

## The Problem

djust LiveViews serialize their state to JSON between requests. This means everything stored as a public instance variable must be JSON-serializable. Service instances -- AWS clients, HTTP sessions, database connections, Redis clients -- are **not** serializable.

If you try to store a service instance in state:

```python
# WRONG: boto3 client is not JSON-serializable
class S3BrowserView(LiveView):
    template_name = "s3_browser.html"

    def mount(self, request, **kwargs):
        self.s3_client = boto3.client("s3")  # Will fail on next render
        self.buckets = []
```

By default djust logs `LiveView state contains non-serializable value: …` and stores a string in its place, so the next handler that calls a method on `self.s3_client` gets an `AttributeError`. With `strict_serialization: True` it raises `TypeError` instead (see [Runtime Warnings and Errors](#runtime-warnings-and-errors)).

The `djust.V006` system check catches this pattern at startup.

---

## Pattern 1: Helper Method (Recommended)

Create a private helper method that instantiates the service on demand. Since private methods (prefixed with `_`) are not serialized, this avoids the problem entirely.

<!-- boto3 is an optional dependency (djust[s3]); not installed here. -->
<!-- doc-snippet-check: skip -->
```python
import boto3
from djust import LiveView, state
from djust.decorators import event_handler


class S3BrowserView(LiveView):
    template_name = "s3_browser.html"

    buckets = state(default=[])
    selected_bucket = state(default="")
    objects = state(default=[])

    def _get_s3_client(self):
        """Create a fresh S3 client for each request."""
        return boto3.client("s3")

    def mount(self, request, **kwargs):
        client = self._get_s3_client()
        response = client.list_buckets()
        self.buckets = [b["Name"] for b in response["Buckets"]]

    @event_handler()
    def select_bucket(self, bucket_name: str = "", **kwargs):
        self.selected_bucket = bucket_name
        client = self._get_s3_client()
        response = client.list_objects_v2(Bucket=bucket_name, MaxKeys=50)
        self.objects = [
            {"key": obj["Key"], "size": obj["Size"]}
            for obj in response.get("Contents", [])
        ]
```

**Why this works**: The `boto3.client()` call happens inside the method, creates a fresh client, and the client reference is never stored on `self` as a public attribute. Only the serializable results (`buckets`, `objects`) are stored in state.

---

## Pattern 2: Plain Dicts for External API Data

When you need to display data from an external API in a structured way, keep it as plain dicts. Don't wrap it in table-less (`managed = False`) model instances: djust stores model instances held in private state as a model reference and re-fetches them with `objects.get(pk=...)` when state is restored, so a model with no table comes back as `None`.

```python
# views.py
import httpx
from djust import LiveView, state
from djust.decorators import event_handler


class ProductCatalogView(LiveView):
    template_name = "catalog.html"

    search_query = state(default="")

    def _fetch_products(self, query=""):
        """Fetch products from external API and return them as plain dicts."""
        response = httpx.get(
            "https://api.example.com/products",
            params={"q": query},
        )
        data = response.json()
        return [
            {
                "id": item["id"],
                "name": item["name"],
                "price": item["price"],
                "sku": item["sku"],
                "in_stock": item["available"],
            }
            for item in data["results"]
        ]

    def mount(self, request, **kwargs):
        self._products = self._fetch_products()

    @event_handler()
    def search(self, value: str = "", **kwargs):
        self.search_query = value
        self._products = self._fetch_products(query=value)

    def get_context_data(self, **kwargs):
        self.products = self._products  # JIT serialization
        return super().get_context_data(**kwargs)
```

This pattern combines the [JIT serialization pattern](../../JIT_SERIALIZATION_PATTERN.md) with plain-dict data for a clean separation.

---

## Pattern 3: Dependency Injection

Pass services via `mount()` kwargs from your URL configuration. This is useful when you want to test views with mock services.

<!-- `myapp.services` is the reader's own module, not one djust ships. -->
<!-- doc-snippet-check: skip -->
```python
# views.py
from djust import LiveView, state
from djust.decorators import event_handler
from myapp.services import get_metrics_client  # your factory


class DashboardView(LiveView):
    template_name = "dashboard.html"

    metrics = state(default=[])

    def mount(self, request, **kwargs):
        # Create the service ON-DEMAND and store it privately.
        #
        # URL kwargs DO reach `mount()` — `runtime.py:2117` does
        # `mount_kwargs.update(self._resolve_url_kwargs(page_url))`, and
        # `_resolve_url_kwargs` (`runtime.py:4096`) returns
        # `dict(resolve(page_url).kwargs)`, which includes the extra-options
        # dict of `path(route, view, {...})`. So URL-level injection is
        # available. What is NOT available is passing a service through
        # `live_session`: that is a URL-pattern grouping helper with the
        # signature `live_session(prefix, patterns, session_name=None)`
        # (`djust/routing.py:105`) and takes no view kwargs.
        #
        # A factory call here is still preferred for a live CLIENT: it keeps
        # the object off `self` as public state, and a connection or client
        # is not serializable.
        self._metrics_client = get_metrics_client()  # Private: not serialized
        if self._metrics_client:
            self.metrics = self._metrics_client.get_recent(limit=20)

    @event_handler()
    def refresh_metrics(self, **kwargs):
        if self._metrics_client:
            self.metrics = self._metrics_client.get_recent(limit=20)
```

Note that `self._metrics_client` uses the private prefix (`_`), so it is excluded from state serialization. For tests, monkeypatch `get_metrics_client` or inject a stub before calling `mount()`.

---

## What's Serializable

These types can be stored as public state variables:

| Type | Example |
|------|---------|
| Primitives | `str`, `int`, `float`, `bool`, `None` |
| Collections | `list`, `dict`, `tuple`, `set` |
| Django models | `Product.objects.get(pk=1)` |
| QuerySets | `Product.objects.filter(active=True)` |
| Dates/times | `datetime.date`, `datetime.datetime` |
| UUIDs | `uuid.UUID` |
| Decimals | `decimal.Decimal` |
| Nested structures | `{"items": [{"name": "Widget", "price": 9.99}]}` |

---

## What's Not Serializable

These types cannot be serialized as public state (by default djust warns and stores a string in their place; with `strict_serialization: True` it raises `TypeError`):

| Type | Example | Use Helper Method Instead |
|------|---------|---------------------------|
| Service clients | `boto3.client("s3")` | `self._get_s3_client()` |
| HTTP sessions | `httpx.Client()`, `requests.Session()` | `self._get_http_client()` |
| Database connections | `psycopg2.connect(...)` | Use Django ORM |
| Redis clients | `redis.Redis()` | `self._get_redis()` |
| Open file handles | `open("data.csv")` | Read and close in method |
| Thread/process objects | `threading.Thread(...)` | Use Celery tasks |
| WebSocket connections | `websockets.connect(...)` | Use `push_event` |
| Generators | `(x for x in range(10))` | Convert to `list()` |
| Lambda functions | `lambda x: x + 1` | Use regular methods |

---

## Detection and Debugging

### System Checks: V006 and V008

djust provides two static checks to catch non-serializable state at development time:

```bash
python manage.py check --tag djust
```

**V006** (warning) detects service-like instances: a `self.x = Call(...)` in `mount()` whose call name contains "Service", "Client", "Session", "API", or "Connection" (case-insensitive):

```
(djust.V006) myapp/views.py:15 -- Service instance 'api_client' assigned in mount(). Service instances cannot be serialized.
    HINT: Use a helper method pattern instead. See: docs/guides/services.md
```

**V008** (info, broader check, issue #292) detects any non-primitive type assignment in mount():

```
(djust.V008) myapp/views.py:15 -- Non-primitive type 'MyServiceClass' assigned to self.service in mount().
    Ensure this type is JSON-serializable.
    HINT: If 'MyServiceClass' is not serializable, use self._service instead or re-initialize in event handlers.
```

V008 catches a broader set of potential serialization issues beyond just service instances.

### Runtime Warnings and Errors

**Default behavior (strict_serialization=False):**

When a non-serializable object is encountered at runtime, djust logs a warning and converts it to a string as a fallback:

```
WARNING [djust.serialization] LiveView state contains non-serializable value: S3Client (from botocore.client).
This will be converted to a string, which may cause AttributeError on deserialization.
Consider using self._<attr> for private state, or re-initialize in mount()/event handlers.
```

On the next request, deserialized state will contain a string like `"<botocore.client.S3 object at 0x...>"` instead of the original service instance, causing AttributeError when you try to call methods on it.

**Strict mode (strict_serialization=True):**

Enable strict mode to catch these issues early during development:

```python
# settings.py
LIVEVIEW_CONFIG = {
    'strict_serialization': True,  # Raise TypeError instead of str() fallback
}
```

With strict mode enabled, non-serializable values raise an immediate TypeError with an actionable error message:

```
TypeError: LiveView state contains non-serializable value: S3Client (from botocore.client).
This will be converted to a string, which may cause AttributeError on deserialization.
Consider using self._<attr> for private state, or re-initialize in mount()/event handlers.
```

The stack trace will point to the serialization step. The fix is always the same: move the service to a private variable or a helper method.

### Debugging Steps

1. Find the public attribute causing the error (look at the `TypeError` message)
2. Rename it with a `_` prefix to make it private: `self.client` -> `self._client`
3. Create a helper method if you need to re-create the service: `self._get_client()`
4. Store only the serializable results in public state

---

## Common Patterns

### AWS / Boto3

```python
class S3View(LiveView):
    template_name = "s3.html"

    files = state(default=[])

    def _s3(self):
        return boto3.client("s3")

    def mount(self, request, **kwargs):
        resp = self._s3().list_objects_v2(Bucket="my-bucket")
        self.files = [obj["Key"] for obj in resp.get("Contents", [])]

    @event_handler()
    def delete_file(self, key: str = "", **kwargs):
        self._s3().delete_object(Bucket="my-bucket", Key=key)
        self.files = [f for f in self.files if f != key]
```

### External REST APIs

```python
import httpx


class WeatherView(LiveView):
    template_name = "weather.html"

    city = state(default="London")
    forecast = state(default={})

    def _fetch_weather(self, city):
        resp = httpx.get(
            "https://api.weather.example.com/forecast",
            params={"city": city},
        )
        return resp.json()

    def mount(self, request, **kwargs):
        self.forecast = self._fetch_weather(self.city)

    @event_handler()
    def change_city(self, value: str = "", **kwargs):
        self.city = value
        self.forecast = self._fetch_weather(value)
```

### Redis Cache

```python
import redis


class LeaderboardView(LiveView):
    template_name = "leaderboard.html"

    scores = state(default=[])

    def _redis(self):
        return redis.Redis(host="localhost", port=6379, db=0)

    def mount(self, request, **kwargs):
        self._load_scores()

    def _load_scores(self):
        r = self._redis()
        raw = r.zrevrange("leaderboard", 0, 9, withscores=True)
        self.scores = [
            {"name": name.decode(), "score": int(score)}
            for name, score in raw
        ]

    @event_handler()
    def refresh(self, **kwargs):
        self._load_scores()
```

---

## See Also

- [Best Practices](BEST_PRACTICES.md) -- State management conventions
- [JIT Serialization Pattern](../../JIT_SERIALIZATION_PATTERN.md) -- Private/public variable pattern
- [Error Codes](error-codes.md) -- V006 and other check details
