Polyglot Development in Platform Engineering
Modern platform engineering teams increasingly work with multiple programming languages, each selected for its specific strengths. This polyglot approach enables teams to choose the best tool for each job rather than forcing every solution into a single language paradigm. However, managing multiple languages introduces complexity in tooling, standards, and team coordination that requires deliberate architectural patterns and practices.
The Case for Polyglot Architecture
A well-designed platform leverages multiple languages strategically across different layers:
graph TB
A[Platform Architecture] --> B[API Layer
Go/C#]
A --> C[Automation Layer
Python]
A --> D[CLI Tools
Go]
A --> E[Integration Services
Python/C#]
B --> B1[High-performance APIs]
B --> B2[Authentication services]
B --> B3[State management]
C --> C1[Deployment scripts]
C --> C2[Infrastructure provisioning]
C --> C3[Data processing]
D --> D1[Developer tools]
D --> D2[Operations utilities]
D --> D3[Admin commands]
E --> E1[Cloud provider SDKs]
E --> E2[External API clients]
E --> E3[Legacy system integration]
This architecture recognizes that different problems have different optimal solutions. The core platform services benefit from Go's performance and deployment simplicity, while automation tasks leverage Python's extensive ecosystem and rapid development capabilities.
Integration Patterns
Polyglot systems require clear boundaries and communication protocols between components written in different languages:
HTTP API Integration
The most common integration pattern uses HTTP APIs with well-defined contracts:
sequenceDiagram
participant PY as Python Automation
participant API as Go API Service
participant DB as Database
participant CS as C# Azure Service
PY->>API: POST /api/provision
API->>DB: Store request
API->>CS: Call Azure provisioning
CS-->>API: Return resource ID
API-->>PY: Return provision result
Go API Service:
package main
import (
"encoding/json"
"net/http"
)
type ProvisionRequest struct {
Environment string `json:"environment"`
Size string `json:"size"`
Region string `json:"region"`
}
type ProvisionResponse struct {
ResourceID string `json:"resource_id"`
Status string `json:"status"`
Message string `json:"message"`
}
func handleProvision(w http.ResponseWriter, r *http.Request) {
var req ProvisionRequest
if err := json.NewDecoder(r.Body).Decrypt(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process provisioning logic
resourceID := provisionResource(req)
resp := ProvisionResponse{
ResourceID: resourceID,
Status: "created",
Message: "Environment provisioned successfully",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func main() {
http.HandleFunc("/api/provision", handleProvision)
http.ListenAndServe(":8080", nil)
}
Python Client:
import requests
import logging
from typing import Dict, Optional
from dataclasses import dataclass
@dataclass
class ProvisionResult:
resource_id: str
status: str
message: str
class PlatformClient:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
})
def provision_environment(
self,
environment: str,
size: str,
region: str
) -> Optional[ProvisionResult]:
"""Call Go API service to provision environment."""
try:
response = self.session.post(
f'{self.base_url}/api/provision',
json={
'environment': environment,
'size': size,
'region': region
},
timeout=30
)
response.raise_for_status()
data = response.json()
return ProvisionResult(
resource_id=data['resource_id'],
status=data['status'],
message=data['message']
)
except requests.RequestException as e:
logging.error(f"Provisioning failed: {e}")
return None
# Usage in automation script
def deploy_application(env: str):
client = PlatformClient(
base_url='http://platform-api:8080',
api_key=get_api_key()
)
result = client.provision_environment(
environment=env,
size='medium',
region='us-west-2'
)
if result and result.status == 'created':
logging.info(f"Provisioned: {result.resource_id}")
deploy_to_resource(result.resource_id)
else:
logging.error("Provisioning failed")
raise Exception("Unable to provision environment")
Message Queue Integration
For asynchronous operations, message queues provide language-agnostic communication:
sequenceDiagram
participant PY as Python Script
participant Q as RabbitMQ
participant GO as Go Worker
participant CS as C# Service
PY->>Q: Publish deployment job
GO->>Q: Consume job
GO->>CS: Trigger Azure update
CS-->>GO: Update complete
GO->>Q: Publish result
PY->>Q: Consume result
Python Publisher:
import pika
import json
class JobPublisher:
def __init__(self, rabbitmq_url: str):
self.connection = pika.BlockingConnection(
pika.URLParameters(rabbitmq_url)
)
self.channel = self.connection.channel()
self.channel.queue_declare(queue='deployment_jobs', durable=True)
def publish_deployment_job(self, deployment_config: dict):
message = json.dumps(deployment_config)
self.channel.basic_publish(
exchange='',
routing_key='deployment_jobs',
body=message,
properties=pika.BasicProperties(
delivery_mode=2, # Make message persistent
content_type='application/json'
)
)
print(f"Published deployment job: {deployment_config['app_name']}")
# Usage
publisher = JobPublisher('amqp://rabbitmq:5672')
publisher.publish_deployment_job({
'app_name': 'web-api',
'version': 'v1.2.3',
'environment': 'production',
'replicas': 3
})
Go Consumer:
package main
import (
"encoding/json"
"log"
"github.com/streadway/amqp"
)
type DeploymentJob struct {
AppName string `json:"app_name"`
Version string `json:"version"`
Environment string `json:"environment"`
Replicas int `json:"replicas"`
}
func processDeployment(job DeploymentJob) error {
log.Printf("Processing deployment: %s@%s", job.AppName, job.Version)
// Execute deployment logic
err := deployApplication(job)
if err != nil {
return err
}
log.Printf("Deployment complete: %s", job.AppName)
return nil
}
func main() {
conn, err := amqp.Dial("amqp://rabbitmq:5672")
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Failed to open channel: %v", err)
}
defer ch.Close()
msgs, err := ch.Consume(
"deployment_jobs", // queue
"", // consumer
false, // auto-ack
false, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
if err != nil {
log.Fatalf("Failed to register consumer: %v", err)
}
for msg := range msgs {
var job DeploymentJob
if err := json.Unmarshal(msg.Body, &job); err != nil {
log.Printf("Error parsing job: %v", err)
msg.Nack(false, false) // Don't requeue invalid messages
continue
}
if err := processDeployment(job); err != nil {
log.Printf("Deployment failed: %v", err)
msg.Nack(false, true) // Requeue for retry
} else {
msg.Ack(false)
}
}
}
CLI Tool Orchestration
CLI tools written in different languages can be orchestrated through shell scripts or higher-level automation:
import subprocess
import json
from typing import Dict, List
class ToolOrchestrator:
"""Orchestrate CLI tools written in different languages."""
def run_go_cli(self, args: List[str]) -> Dict:
"""Execute Go-based CLI tool."""
try:
# Go tools are single binaries - easy to execute
result = subprocess.run(
['./bin/platform-cli'] + args,
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
raise Exception(f"CLI tool failed: {e.stderr}")
def run_python_script(self, script: str, args: Dict) -> Dict:
"""Execute Python automation script."""
try:
# Python scripts may need virtual environment
result = subprocess.run(
['python', f'scripts/{script}.py', json.dumps(args)],
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
raise Exception(f"Script failed: {e.stderr}")
def run_csharp_tool(self, dll: str, args: List[str]) -> Dict:
"""Execute .NET CLI tool."""
try:
# .NET tools can be single-file or require runtime
result = subprocess.run(
['dotnet', f'tools/{dll}'] + args,
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
raise Exception(f"Tool failed: {e.stderr}")
# Complete workflow using multiple languages
def provision_full_environment(env_name: str):
orchestrator = ToolOrchestrator()
# Step 1: Use Go CLI to validate environment
validation = orchestrator.run_go_cli([
'validate',
'--environment', env_name
])
if not validation['valid']:
raise Exception(f"Validation failed: {validation['errors']}")
# Step 2: Use Python script to provision infrastructure
infra = orchestrator.run_python_script('provision_infra', {
'environment': env_name,
'region': validation['recommended_region']
})
# Step 3: Use C# tool to configure Azure resources
azure_config = orchestrator.run_csharp_tool('AzureConfig.dll', [
'--subscription', infra['subscription_id'],
'--resource-group', infra['resource_group']
])
return {
'infrastructure': infra,
'azure': azure_config,
'status': 'provisioned'
}
Code Organization and Project Structure
Organizing a polyglot codebase requires clear conventions:
platform-repo/
├── services/
│ ├── api-gateway/ # Go service
│ │ ├── cmd/
│ │ ├── internal/
│ │ ├── go.mod
│ │ └── Dockerfile
│ ├── azure-manager/ # C# service
│ │ ├── src/
│ │ ├── tests/
│ │ ├── AzureManager.sln
│ │ └── Dockerfile
│ └── common/ # Shared contracts
│ ├── api-schemas/
│ └── message-schemas/
├── automation/
│ ├── deployment/ # Python scripts
│ │ ├── deploy.py
│ │ ├── rollback.py
│ │ └── requirements.txt
│ ├── infrastructure/ # Python/Terraform
│ │ ├── provision.py
│ │ └── terraform/
│ └── monitoring/ # Python scripts
│ └── check_health.py
├── tools/
│ ├── platform-cli/ # Go CLI
│ │ ├── cmd/
│ │ └── go.mod
│ └── config-validator/ # C# tool
│ └── ConfigValidator.csproj
├── docs/
│ ├── api-contracts/ # OpenAPI specs
│ ├── message-schemas/ # JSON schemas
│ └── architecture/
└── .github/
└── workflows/ # CI/CD for all languages
Key Organizational Principles:
- Language-specific directories: Group code by service/tool, not by language
- Shared contracts: Maintain API and message schemas in a common location
- Independent build systems: Each service has its own build configuration
- Consistent naming: Follow language conventions within each codebase
Build and CI/CD Configuration
A polyglot repository requires sophisticated CI/CD that understands multiple build systems:
# .github/workflows/build.yml
name: Build All Services
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
go-services: ${{ steps.filter.outputs.go }}
python-scripts: ${{ steps.filter.outputs.python }}
csharp-services: ${{ steps.filter.outputs.csharp }}
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
go:
- 'services/api-gateway/**'
- 'tools/platform-cli/**'
python:
- 'automation/**/*.py'
- 'automation/**/requirements.txt'
csharp:
- 'services/azure-manager/**'
- 'tools/config-validator/**'
build-go:
needs: detect-changes
if: needs.detect-changes.outputs.go-services == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build Go services
run: |
cd services/api-gateway
go mod download
go build -o ../../bin/api-gateway ./cmd/api-gateway
go test ./...
- name: Build Go CLI
run: |
cd tools/platform-cli
go build -o ../../bin/platform-cli ./cmd/cli
test-python:
needs: detect-changes
if: needs.detect-changes.outputs.python-scripts == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pylint
cd automation/deployment
pip install -r requirements.txt
- name: Lint Python code
run: |
pylint automation/**/*.py
- name: Run tests
run: |
pytest automation/tests/
build-csharp:
needs: detect-changes
if: needs.detect-changes.outputs.csharp-services == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0'
- name: Build C# services
run: |
cd services/azure-manager
dotnet restore
dotnet build --configuration Release
dotnet test
- name: Build C# tools
run: |
cd tools/config-validator
dotnet build --configuration Release
Tooling Standardization
Despite using multiple languages, standardize developer tooling:
graph TB
A[Developer Tooling] --> B[IDE/Editor
VS Code]
A --> C[Containerization
Docker]
A --> D[Orchestration
Docker Compose]
A --> E[Code Quality
Pre-commit Hooks]
B --> B1[Language extensions]
B --> B2[Unified debugging]
B --> B3[Integrated terminals]
C --> C1[Consistent runtimes]
C --> C2[Isolated environments]
C --> C3[Reproducible builds]
D --> D1[Local development]
D --> D2[Integration testing]
D --> D3[Service dependencies]
E --> E1[Linting all languages]
E --> E2[Format checking]
E --> E3[Security scanning]
Docker Compose for Local Development:
# docker-compose.yml
version: '3.8'
services:
api-gateway:
build:
context: ./services/api-gateway
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://postgres:5432/platform
- RABBITMQ_URL=amqp://rabbitmq:5672
depends_on:
- postgres
- rabbitmq
azure-manager:
build:
context: ./services/azure-manager
dockerfile: Dockerfile
ports:
- "8081:80"
environment:
- AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID}
- ConnectionStrings__Database=Host=postgres;Database=platform
automation-runner:
build:
context: ./automation
dockerfile: Dockerfile.python
volumes:
- ./automation:/app
- ./bin:/bin/tools
environment:
- API_GATEWAY_URL=http://api-gateway:8080
- PYTHONUNBUFFERED=1
postgres:
image: postgres:15
environment:
- POSTGRES_DB=platform
- POSTGRES_PASSWORD=dev_password
volumes:
- postgres_data:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:3-management
ports:
- "15672:15672"
volumes:
postgres_data:
Developers can spin up the entire polyglot platform with a single command:
docker-compose up -d
Testing Strategies
Testing a polyglot system requires multiple approaches:
Unit Tests (Language-Specific)
Each language uses its native testing framework:
# automation/tests/test_deployment.py
import pytest
from deployment.deploy import DeploymentManager
def test_deployment_validation():
manager = DeploymentManager()
# Valid configuration should pass
valid_config = {
'app_name': 'web-api',
'version': 'v1.0.0',
'environment': 'staging'
}
assert manager.validate_config(valid_config) == True
# Invalid configuration should fail
invalid_config = {'app_name': 'web-api'}
assert manager.validate_config(invalid_config) == False
// services/api-gateway/internal/provision/provision_test.go
package provision
import (
"testing"
)
func TestProvisionEnvironment(t *testing.T) {
provisioner := NewProvisioner()
req := ProvisionRequest{
Environment: "staging",
Size: "medium",
Region: "us-west-2",
}
result, err := provisioner.Provision(req)
if err != nil {
t.Fatalf("Provisioning failed: %v", err)
}
if result.Status != "created" {
t.Errorf("Expected status 'created', got '%s'", result.Status)
}
}
Integration Tests (Cross-Language)
Test interactions between services written in different languages:
# integration_tests/test_full_workflow.py
import requests
import subprocess
import time
def test_end_to_end_deployment():
"""Test complete workflow across Go API, Python scripts, and C# services."""
# Start all services
subprocess.run(['docker-compose', 'up', '-d'], check=True)
time.sleep(5) # Wait for services to be ready
# Step 1: Call Go API to initiate provisioning
provision_response = requests.post(
'http://localhost:8080/api/provision',
json={
'environment': 'test-env',
'size': 'small',
'region': 'us-west-2'
}
)
assert provision_response.status_code == 200
resource_id = provision_response.json()['resource_id']
# Step 2: Verify Python automation script can query status
status_response = requests.get(
f'http://localhost:8080/api/status/{resource_id}'
)
assert status_response.status_code == 200
assert status_response.json()['status'] == 'provisioned'
# Step 3: Verify C# service processed Azure configuration
azure_response = requests.get(
f'http://localhost:8081/api/azure/resources/{resource_id}'
)
assert azure_response.status_code == 200
assert azure_response.json()['configured'] == True
# Cleanup
subprocess.run(['docker-compose', 'down'], check=True)
Contract Testing
Ensure APIs remain compatible across language boundaries:
# contract_tests/test_api_contracts.py
import jsonschema
import requests
import json
def test_provision_api_contract():
"""Verify API adheres to OpenAPI contract."""
# Load OpenAPI specification
with open('docs/api-contracts/provision-api.json') as f:
schema = json.load(f)
# Make API call
response = requests.post(
'http://localhost:8080/api/provision',
json={
'environment': 'test',
'size': 'small',
'region': 'us-west-2'
}
)
# Validate response matches schema
jsonschema.validate(
instance=response.json(),
schema=schema['components']['schemas']['ProvisionResponse']
)
Team Coordination
Managing a polyglot codebase requires clear team practices:
Code Review Standards
# Code Review Guidelines
## Language-Specific Reviewers
- Go services: @platform-go-team
- C# services: @platform-csharp-team
- Python scripts: @devops-team
## Cross-Language Reviews
All changes touching API contracts or message schemas require:
1. Review from service owner
2. Review from consuming service teams
3. Documentation update
## Common Standards (All Languages)
- [ ] Tests included for new functionality
- [ ] Error handling follows team conventions
- [ ] Logging includes appropriate context
- [ ] Documentation updated
- [ ] API contracts updated if applicable
Documentation Requirements
Maintain language-agnostic documentation:
# Service: Environment Provisioning API
## Purpose
Provisions new development environments on demand.
## API Endpoint
POST /api/provision
## Request Schema
\`\`\`json
{
"environment": "string (required)",
"size": "small|medium|large (required)",
"region": "string (required)"
}
\`\`\`
## Response Schema
\`\`\`json
{
"resource_id": "string",
"status": "created|pending|failed",
"message": "string"
}
\`\`\`
## Implementation Details
- **Service**: api-gateway (Go)
- **Dependencies**: azure-manager (C#), postgres
- **Called By**: automation scripts (Python), CLI tools (Go)
## Performance Characteristics
- Average latency: 200ms
- Timeout: 30s
- Rate limit: 100 requests/minute per user
Common Pitfalls
Inconsistent Error Handling: Different languages have different error handling idioms. Establish conventions for HTTP status codes and error response formats across all services.
Dependency Versioning Conflicts: Python's pip, Go's modules, and .NET's NuGet have different versioning approaches. Use containers to isolate dependencies and avoid conflicts.
Build System Complexity: Avoid making the build system too clever. Keep language-specific builds simple and use CI/CD orchestration for cross-language workflows.
Over-Engineering Abstractions: Don't try to create language-agnostic abstractions for everything. Embrace each language's strengths and idioms within its domain.
Insufficient Contract Testing: Changes to API contracts can break multiple services. Invest in contract testing and schema validation to catch breaking changes early.
Knowledge Silos: Teams that only work in one language create bottlenecks. Encourage polyglot learning and pair programming across language boundaries.
Key Takeaways
- Polyglot architecture leverages each language's strengths - Go for performance-critical services, Python for automation, C# for enterprise integration
- HTTP APIs and message queues provide language-agnostic integration patterns between services
- Organize code by service/domain rather than by language to maintain clear boundaries and ownership
- Standardize developer tooling with Docker, Docker Compose, and VS Code to provide consistent experience across languages
- Use path-based CI/CD filtering to build only the services affected by code changes, reducing build times
- Maintain shared API and message schemas in a common location with version control and documentation
- Integration tests that span multiple languages are critical for catching compatibility issues between services
- Contract testing ensures API changes don't break consumers written in different languages
- Establish clear code review standards with language-specific reviewers and cross-language requirements for API changes
- Document services in language-agnostic terms focusing on contracts, behavior, and performance characteristics
- Avoid creating complex language-agnostic abstractions - embrace each language's idioms within its domain
- Encourage polyglot learning to prevent knowledge silos and enable team members to work across the stack
- Use containers to isolate dependencies and avoid conflicts between different language package managers
- Focus on service boundaries and API contracts rather than trying to standardize implementation patterns across languages