Milestone 01 — Backend: parte 01: SDUI e configurações
Contract-first no backend do Pix — fronteiras que escalam
Milestone 01 — Backend: parte 01: SDUI e configurações
Contract-first no backend do Pix — fronteiras que escalam

1. Contexto: quatro serviços, quatro vocabulários
Um contrato compartilhado pode parecer conveniente hoje e se tornar a forma mais silenciosa de acoplar serviços amanhã.
Leitura sugerida antes desse artigo:
1. Contexto e sequência do fluxo
Foram definidos dois endpoints para o SDUI porque configuração e tela respondem perguntas diferentes:
GET /mobile/v1/configuration: Quais features este cliente deve apresentar?
GET /mobile/v1/screens/{screenId}: Como uma tela específica deve ser composta?
""" arquivo utils.py"""
RESOURCES_DIRECTORY = Path(__file__).resolve().parent / "resources"
CONFIGURATION_FILE = RESOURCES_DIRECTORY / "configuration.json"
SCREENS_FILE = RESOURCES_DIRECTORY / "screens.json"
def read_configuration() -> dict[str, Any]:
"""Return the published configuration document."""
return read_json_file(CONFIGURATION_FILE)
def read_screens() -> dict[str, Any]:
"""Return the published screens collection."""
return read_json_file(SCREENS_FILE)
O primeiro carrega [resources/configuration.json](https://github.com/JosielManzonni/pix-jsr-backend/blob/main/sdui-service/resources/configuration.json). Esse documento contém as features avaliadas para o aplicativo, como pix_qr e pix_nfc, indicando visibilidade, habilitação e metadados de apresentação.
py@app.get(
"/mobile/v1/configuration",
response_model=AppConfiguration,
responses={
400: {"model": Problem},
404: {"model": Problem},
406: {"model": Problem},
426: {"model": AppUpdateProblem},
500: {"model": Problem},
},
tags=["Mobile configuration"],
)
async def get_mobile_configuration(
context: MobileContext,
response: Response,
) -> AppConfiguration | Response:
"""Return the static configuration as its generated contract model."""
configuration = _load_configuration()
serialized = configuration.model_dump(mode="json", exclude_none=True)
etag = build_etag(serialized, "configuration")
headers = _representation_headers(
context,
contract_version=configuration.contractVersion,
etag=etag,
revision_header="X-Configuration-Revision",
revision=configuration.revision or 1,
)
if context.if_none_match == etag:
return Response(status_code=304, headers=headers)
_apply_headers(response, headers)
return configuration
Depois, o cliente pode solicitar uma tela. O segundo endpoint lê [resources/screens.json](https://github.com/JosielManzonni/pix-jsr-backend/blob/main/sdui-service/resources/screens.json), procura o screenId e devolve a árvore de componentes nativos, como text, spacer e feature.
@app.get(
"/mobile/v1/screens/{screenId}",
response_model=Screen,
responses={
400: {"model": Problem},
404: {"model": Problem},
406: {"model": Problem},
426: {"model": AppUpdateProblem},
500: {"model": Problem},
},
tags=["Mobile screens"],
)
async def get_mobile_screen(
screen_id: Annotated[ResourceId, Path(alias="screenId")],
context: SduiContext,
response: Response,
) -> Screen | Response:
"""Return one static screen as its generated contract model."""
screen = _load_screen(screen_id)
if context.contract_version < screen.contractVersion:
raise ApiProblemError(
406,
"Unsupported SDUI contract",
"The client cannot safely interpret this screen contract.",
"SDUI_UNSUPPORTED_CONTRACT",
)
_validate_component_compatibility(screen, context)
serialized = screen.model_dump(mode="json", exclude_none=True)
etag = build_etag(serialized, f"screen-{screen_id}")
headers = _representation_headers(
context,
contract_version=screen.contractVersion,
etag=etag,
revision_header="X-Screen-Revision",
revision=screen.revision or 1,
)
if context.if_none_match == etag:
return Response(status_code=304, headers=headers)
_apply_headers(response, headers)
return screen
Essa ordem forma um fluxo fácil de observar: configuração informa quais entradas existem; screen descreve a experiência escolhida. Ela não é uma obrigação protocolar — o cliente pode abrir uma tela conhecida diretamente — , mas representa o uso esperado na home.
2. Fronteiras contract-first
Cada domínio mantém seu próprio contrato:

As alternativas estão detalhadas na
A escolha de contratos locais foi formalizada na
Um OpenAPI único facilitaria descoberta, mas permitiria que componentes visuais referenciassem modelos internos de pagamento. Contratos locais aceitam alguma repetição para preservar ownership e releases independentes.
3. Arquitetura e escala

SDUI possui carga predominantemente de leitura. Para milhões de usuários, a evolução natural inclui cache local, ETag com revalidação 304, revisões imutáveis, cache de representação e instâncias stateless. Isso pode escalar sem aumentar também instâncias do lifecycle de pagamentos.
A fonte editável está em docs/diagrams/01-backend-contract-boundaries.mmd.
4. Headers e segurança
Os dois endpoints não usam autenticação neste milestone, mas exigem contexto mobile. Esses valores são declarações do cliente: ajudam compatibilidade, localização e cache, porém nunca autorizam uma operação de negócio.

5. Executando localmente com Uvicorn
No terminal, a partir da raiz do repositório:
cd sdui-service
source .venv-sdui/bin/activate
uvicorn main:app --reload --host 127.0.0.1 --port 8000
O serviço fica em http://127.0.0.1:8000. A documentação runtime pode ser consultada em /docs.

No Postman (ou usando curls), requests GET: informe URL e headers.
Se algum header obrigatório estiver faltando, é esperado esse comportamento
request errada:
curl --location 'http://127.0.0.1:8000/mobile/v1/configuration'
response:
{
"type": "https://openpix.dev/problems/context-invalid-request",
"title": "Invalid request context",
"status": 400,
"detail": "Invalid or missing request value: X-App-Platform, X-App-Version, X-App-Build, X-App-Package, X-Country-Code, Accept-Language.",
"instance": "/mobile/v1/configuration",
"code": "CONTEXT_INVALID_REQUEST",
"correlationId": "7c535dca-c7f2-4ec1-9fa3-c26f754db234"
}
request correta:
curl --location --request GET 'http://127.0.0.1:8000/mobile/v1/configuration' \
--header 'X-App-Platform: android' \
--header 'X-App-Version: 1.4.2' \
--header 'X-App-Build: 10402' \
--header 'X-App-Package: com.openpix.jsr' \
--header 'X-Country-Code: BR' \
--header 'Accept-Language: pt-BR' \
--header 'X-SDUI-Contract-Version: 1' \
--header 'X-SDUI-Component-Versions: text=1,spacer=1,feature=1' \
--header 'Content-Type: application/json' \
--data ''
response:
{
"contractVersion": 1,
"revision": null,
"compatibility": null,
"features": [
{
"id": "pix_qr",
"visible": true,
"enabled": false,
"disabledMessage": null,
"presentation": {
"title": "QR Code",
"subtitle": "Escaneie um código Pix",
"icon": "qr_code",
"style": "card"
}
},
{
"id": "pix_nfc",
"visible": true,
"enabled": false,
"disabledMessage": "Disponível em breve",
"presentation": {
"title": "Pix por aproximação",
"subtitle": "Aproxime o celular para pagar",
"icon": "pix_nfc",
"style": "card"
}
}
]
}
Endpoint GET screens/{screenId}
Definição e motivo
GET /mobile/v1/screens/{screenId}
Alterar a revisão de pix_home não exige inventar outro ID (existente em screen.json).
Parâmetros esperados
O único path parameter é screenId, validado no formato
^[a-z][a-z0–9_]{1,63}$. Não há query parameter ou body. Além dos headers mobile, a chamada exige a versão do contrato e o mapa de componentes.
Como testar no Postman
GET http://127.0.0.1:8000/mobile/v1/screens/pix_home
X-App-Platform: android
X-App-Version: 1.4.2
X-App-Build: 10402
X-App-Package: com.openpix.jsr
X-Country-Code: BR
Accept-Language: pt-BR
X-App-Capabilities: PIX_QR,PIX_NFC,PAYMENT
X-SDUI-Contract-Version: 1
X-SDUI-Component-Versions: text=1,spacer=1,feature=1
Uma resposta 200 começa assim:
{
"contractVersion": 1,
"id": "pix_home",
"title": "Pix",
"components": [
{
"id": "home_title",
"type": "text",
"compatibilityVersion": ["1"],
"text": "Como deseja pagar?",
"style": "title"
}
]
}
Use um screenId inexistente para validar o 404. Troque feature=1 por uma versão não suportada para observar o 406. Reenvie o ETag recebido em If-None-Match para validar o 304.
6. Implementação e fonte de dados
FastAPI usa dependências tipadas para normalizar headers uma única vez. Os arquivos são lidos por *utils.py* e validados nos modelos Pydantic gerados:
configuration = AppConfiguration.model_validate(read_configuration())
screen = Screen.model_validate(read_screen(screen_id))
O filesystem é temporário e não faz parte do contrato HTTP. A decisão e os critérios para substituí-lo estão na ADR-SDUI-002 — Static JSON runtime source.
7. Trade-offs e conclusão
Separar os endpoints evita misturar disponibilidade de features com composição visual e permite cache e evolução independentes. O custo é manter dois recursos e garantir coerência entre feature IDs e telas publicadas.
O fluxo local agora pode ser reproduzido do início ao fim: iniciar Uvicorn, consultar configuração, escolher screenId, carregar a tela e validar 200, 304, 404 e 406. O próximo artigo aprofunda por que a compatibilidade é verificada tanto no backend quanto no renderer.
8. Materiais de apoio
Próximo capítulo:
Contato
Quer conversar sobre Pix, Open Finance, Android, arquitetura de pagamentos ou Server-Driven UI?
- LinkedIn: @JosielManzonni
- GitHub: @JosielManzonni
- E-mail: josiel.manzonni@gmail.com
- Medium: siga meu perfil para acompanhar os próximos artigos da série **Construindo o Pix**.
메타데이터
- post_id
- e85f27ea4734
- slug
- milestone-01-backend-parte-01-sdui-e-configurações-e85f27ea4734
- url
- https://medium.com/construindo-o-pix-jsr/milestone-01-backend-parte-01-sdui-e-configura%C3%A7%C3%B5es-e85f27ea4734
- canonical_url
- https://medium.com/construindo-o-pix-jsr/milestone-01-backend-parte-01-sdui-e-configura%C3%A7%C3%B5es-e85f27ea4734
- author_url
- https://medium.com/@josielmanzonni
- status
- ok
- fetched_at
- 2026-08-18 11:45:58